Compare commits
No commits in common. "main" and "v0.2.0-song" have entirely different histories.
main
...
v0.2.0-son
|
|
@ -1,32 +0,0 @@
|
||||||
# 설계 에이전트 (Design Agent)
|
|
||||||
|
|
||||||
Python과 FastAPI 전문 설계자로서, 비동기 프로그래밍, 디자인 패턴, 데이터베이스에 대한 전문적인 지식을 보유하고 있습니다.
|
|
||||||
|
|
||||||
## 역할
|
|
||||||
- 사용자의 요구사항을 분석하고 설계 문서를 작성합니다
|
|
||||||
- 기존 프로젝트 패턴과 일관성 있는 아키텍처를 설계합니다
|
|
||||||
- API 엔드포인트, 데이터 모델, 서비스 레이어, 스키마를 설계합니다
|
|
||||||
|
|
||||||
## 수행 절차
|
|
||||||
|
|
||||||
### 1단계: 요구사항 분석
|
|
||||||
- 사용자의 요구사항을 명확히 파악합니다
|
|
||||||
- 기능적 요구사항과 비기능적 요구사항을 분리합니다
|
|
||||||
|
|
||||||
### 2단계: 관련 코드 검토
|
|
||||||
- 프로젝트의 기존 구조와 패턴을 분석합니다
|
|
||||||
- `app/` 디렉토리의 모듈 구조를 확인합니다
|
|
||||||
|
|
||||||
### 3단계: 설계 수행
|
|
||||||
다음 원칙을 준수하여 설계합니다:
|
|
||||||
- **레이어드 아키텍처**: Router → Service → Repository 패턴
|
|
||||||
- **비동기 우선**: 모든 I/O 작업은 async/await 사용
|
|
||||||
- **의존성 주입**: FastAPI의 Depends 활용
|
|
||||||
|
|
||||||
### 4단계: 설계 검수
|
|
||||||
- 기존 프로젝트 패턴과 일관성 확인
|
|
||||||
- N+1 쿼리 문제 검토
|
|
||||||
- SOLID 원칙 준수 여부 확인
|
|
||||||
|
|
||||||
## 출력
|
|
||||||
설계 문서를 화면에 출력합니다.
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# 개발 에이전트 (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가 누락되지 않았는가?
|
|
||||||
- 순환 참조가 발생하지 않는가?
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
# 코드리뷰 에이전트 (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: 코드 스타일, 베스트 프랙티스 권장
|
|
||||||
|
|
||||||
## 출력
|
|
||||||
코드 리뷰 리포트를 화면에 출력합니다.
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
# 설계 에이전트 (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` 명령으로 개발 에이전트를 호출하여 구현을 진행합니다.
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
# 개발 에이전트 (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` 명령으로 코드리뷰 에이전트를 호출하여 최종 검수를 진행합니다.
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
# 코드리뷰 에이전트 (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`)를 통해 수정합니다
|
|
||||||
|
|
@ -6,12 +6,10 @@ __pycache__/
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env*
|
|
||||||
|
|
||||||
# Claude AI related files
|
# Claude AI related files
|
||||||
.claudeignore
|
|
||||||
CLAUDE.md
|
|
||||||
.claude/
|
.claude/
|
||||||
|
.claudeignore
|
||||||
|
|
||||||
# VSCode settings
|
# VSCode settings
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
@ -28,25 +26,4 @@ build/
|
||||||
# Media files
|
# Media files
|
||||||
*.mp3
|
*.mp3
|
||||||
*.mp4
|
*.mp4
|
||||||
media/
|
media/
|
||||||
|
|
||||||
|
|
||||||
*.ipynb_checkpoint*
|
|
||||||
# Static files
|
|
||||||
static/
|
|
||||||
|
|
||||||
# Log files
|
|
||||||
*.log
|
|
||||||
logs/
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
.AppleDouble
|
|
||||||
.LSOverride
|
|
||||||
._*
|
|
||||||
.Spotlight-V100
|
|
||||||
.Trashes
|
|
||||||
|
|
||||||
*.yml
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
3.13.11
|
3.13
|
||||||
|
|
|
||||||
53
CLAUDE.md
53
CLAUDE.md
|
|
@ -1,53 +0,0 @@
|
||||||
# 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 .
|
|
||||||
```
|
|
||||||
202
README.md
202
README.md
|
|
@ -4,29 +4,24 @@ AI 기반 광고 음악 생성 서비스의 백엔드 API 서버입니다.
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
|
|
||||||
- **Language**: Python 3.13
|
|
||||||
- **Framework**: FastAPI
|
- **Framework**: FastAPI
|
||||||
- **Database**: MySQL (asyncmy 비동기 드라이버), Redis
|
- **Database**: MySQL (asyncmy 비동기 드라이버)
|
||||||
- **ORM**: SQLAlchemy (async)
|
- **ORM**: SQLAlchemy (async)
|
||||||
- **Package Manager**: uv
|
|
||||||
- **AI Services**:
|
- **AI Services**:
|
||||||
- OpenAI ChatGPT (가사 생성, 마케팅 분석)
|
- OpenAI ChatGPT (가사 생성, 마케팅 분석)
|
||||||
- Suno AI (음악 생성)
|
- Suno AI (음악 생성)
|
||||||
- Creatomate (비디오 생성)
|
|
||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app/
|
app/
|
||||||
├── core/ # 핵심 설정 및 공통 모듈 (logging, exceptions)
|
├── core/ # 핵심 설정 및 공통 모듈
|
||||||
├── database/ # 데이터베이스 세션 및 Redis 설정
|
├── database/ # 데이터베이스 세션 및 설정
|
||||||
├── dependencies/ # FastAPI 의존성 주입
|
|
||||||
├── home/ # 홈 API (크롤링, 영상 생성 요청)
|
├── home/ # 홈 API (크롤링, 영상 생성 요청)
|
||||||
├── lyric/ # 가사 API (가사 생성)
|
├── lyric/ # 가사 API (가사 생성)
|
||||||
├── song/ # 노래 API (Suno AI 음악 생성)
|
├── song/ # 노래 API (Suno AI 음악 생성)
|
||||||
├── user/ # 사용자 모듈 (카카오 로그인, JWT 인증)
|
|
||||||
├── video/ # 비디오 관련 모듈
|
├── video/ # 비디오 관련 모듈
|
||||||
└── utils/ # 유틸리티 (ChatGPT, Suno, 크롤러, 프롬프트)
|
└── utils/ # 유틸리티 (ChatGPT, Suno, 크롤러)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API 엔드포인트
|
## API 엔드포인트
|
||||||
|
|
@ -55,90 +50,29 @@ app/
|
||||||
| ------ | -------------------------- | ----------------------------- |
|
| ------ | -------------------------- | ----------------------------- |
|
||||||
| POST | `/song/generate` | Suno AI를 이용한 노래 생성 요청 |
|
| POST | `/song/generate` | Suno AI를 이용한 노래 생성 요청 |
|
||||||
| GET | `/song/status/{task_id}` | 노래 생성 상태 조회 (폴링) |
|
| GET | `/song/status/{task_id}` | 노래 생성 상태 조회 (폴링) |
|
||||||
|
| GET | `/song/download/{task_id}` | 생성된 노래 MP3 다운로드 |
|
||||||
|
|
||||||
## 환경 설정
|
## 환경 설정
|
||||||
|
|
||||||
`.env` 파일에 다음 환경 변수를 설정합니다:
|
`.env` 파일에 다음 환경 변수를 설정합니다:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# ================================
|
# 프로젝트 설정
|
||||||
# 프로젝트 기본 정보
|
PROJECT_NAME=CastAD
|
||||||
# ================================
|
PROJECT_DOMAIN=localhost:8000
|
||||||
PROJECT_NAME=CastAD # 프로젝트 이름
|
DEBUG=True
|
||||||
PROJECT_DOMAIN=localhost:8000 # 프로젝트 도메인 (호스트:포트)
|
|
||||||
PROJECT_VERSION=0.1.0 # 프로젝트 버전
|
|
||||||
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
|
||||||
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
|
||||||
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
|
|
||||||
|
|
||||||
# ================================
|
|
||||||
# MySQL 설정
|
# MySQL 설정
|
||||||
# ================================
|
MYSQL_HOST=localhost
|
||||||
MYSQL_HOST=localhost # MySQL 호스트 주소
|
MYSQL_PORT=3306
|
||||||
MYSQL_PORT=3306 # MySQL 포트 번호
|
MYSQL_USER=your_user
|
||||||
MYSQL_USER=castad-admin # MySQL 사용자명
|
MYSQL_PASSWORD=your_password
|
||||||
MYSQL_PASSWORD=o2o1324 # MySQL 비밀번호
|
MYSQL_DB=castad
|
||||||
MYSQL_DB=castad # 사용할 데이터베이스명
|
|
||||||
|
|
||||||
# ================================
|
# API Keys
|
||||||
# Redis 설정
|
CHATGPT_API_KEY=your_openai_api_key
|
||||||
# ================================
|
SUNO_API_KEY=your_suno_api_key
|
||||||
REDIS_HOST=localhost # Redis 호스트 주소
|
SUNO_CALLBACK_URL=https://your-domain.com/api/suno/callback
|
||||||
REDIS_PORT=6379 # Redis 포트 번호
|
|
||||||
|
|
||||||
# ================================
|
|
||||||
# CORS 설정
|
|
||||||
# ================================
|
|
||||||
CORS_ALLOW_ORIGINS='["*"]' # 허용할 Origin 목록 (JSON 배열 형식)
|
|
||||||
CORS_ALLOW_CREDENTIALS=True # 자격 증명(쿠키 등) 허용 여부
|
|
||||||
CORS_ALLOW_METHODS='["*"]' # 허용할 HTTP 메서드 (JSON 배열 형식)
|
|
||||||
CORS_ALLOW_HEADERS='["*"]' # 허용할 HTTP 헤더 (JSON 배열 형식)
|
|
||||||
CORS_MAX_AGE=600 # Preflight 요청 캐시 시간 (초)
|
|
||||||
|
|
||||||
# ================================
|
|
||||||
# Azure Blob Storage 설정
|
|
||||||
# ================================
|
|
||||||
AZURE_BLOB_SAS_TOKEN=your_sas_token # Azure Blob Storage SAS 토큰
|
|
||||||
AZURE_BLOB_BASE_URL=https://... # Azure Blob Storage 기본 URL
|
|
||||||
|
|
||||||
# ================================
|
|
||||||
# Creatomate 템플릿 설정
|
|
||||||
# ================================
|
|
||||||
TEMPLATE_ID_VERTICAL=your_template_id # 세로형(9:16) 비디오 템플릿 ID
|
|
||||||
TEMPLATE_DURATION_VERTICAL=60.0 # 세로형 비디오 기본 길이 (초)
|
|
||||||
TEMPLATE_ID_HORIZONTAL=your_template_id # 가로형(16:9) 비디오 템플릿 ID
|
|
||||||
TEMPLATE_DURATION_HORIZONTAL=20.0 # 가로형 비디오 기본 길이 (초)
|
|
||||||
|
|
||||||
# ================================
|
|
||||||
# JWT 토큰 설정
|
|
||||||
# ================================
|
|
||||||
JWT_SECRET=your_secret_key # JWT 서명용 비밀키 (랜덤 문자열 권장)
|
|
||||||
JWT_ALGORITHM=HS256 # JWT 알고리즘 (기본: HS256)
|
|
||||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60 # Access Token 만료 시간 (분)
|
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 # Refresh Token 만료 시간 (일)
|
|
||||||
|
|
||||||
# ================================
|
|
||||||
# 프롬프트 설정
|
|
||||||
# ================================
|
|
||||||
PROMPT_FOLDER_ROOT=./app/utils/prompts # 프롬프트 파일 루트 디렉토리
|
|
||||||
MARKETING_PROMPT_NAME=marketing_prompt # 마케팅 분석용 프롬프트 파일명
|
|
||||||
SUMMARIZE_PROMPT_NAME=summarize_prompt # 요약용 프롬프트 파일명
|
|
||||||
LYLIC_PROMPT_NAME=lyric_prompt # 가사 생성용 프롬프트 파일명
|
|
||||||
|
|
||||||
# ================================
|
|
||||||
# 로그 설정
|
|
||||||
# ================================
|
|
||||||
LOG_CONSOLE_ENABLED=True # 콘솔 로그 출력 여부
|
|
||||||
LOG_FILE_ENABLED=True # 파일 로그 저장 여부
|
|
||||||
LOG_LEVEL=DEBUG # 전체 로그 레벨 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
||||||
LOG_CONSOLE_LEVEL=DEBUG # 콘솔 출력 로그 레벨
|
|
||||||
LOG_FILE_LEVEL=DEBUG # 파일 저장 로그 레벨
|
|
||||||
LOG_MAX_SIZE_MB=15 # 로그 파일 최대 크기 (MB)
|
|
||||||
LOG_BACKUP_COUNT=30 # 로그 백업 파일 보관 개수
|
|
||||||
LOG_DIR=logs # 로그 저장 디렉토리 경로
|
|
||||||
# - 절대 경로: 해당 경로 사용
|
|
||||||
# - 상대 경로: 프로젝트 루트 기준
|
|
||||||
# - /www/log/uvicorn 존재 시: 자동으로 해당 경로 사용 (운영)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 실행 방법
|
## 실행 방법
|
||||||
|
|
@ -161,9 +95,6 @@ uv sync
|
||||||
|
|
||||||
# 이미 venv를 만든 경우 (기존 가상환경 활성화 필요)
|
# 이미 venv를 만든 경우 (기존 가상환경 활성화 필요)
|
||||||
uv sync --active
|
uv sync --active
|
||||||
|
|
||||||
playwright install
|
|
||||||
playwright install-deps
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 서버 실행
|
### 서버 실행
|
||||||
|
|
@ -179,100 +110,3 @@ fastapi run main.py
|
||||||
## API 문서
|
## API 문서
|
||||||
|
|
||||||
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
|
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
|
||||||
|
|
||||||
## 서버 아키텍처
|
|
||||||
|
|
||||||
### 전체 시스템 흐름
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Client (Web/Mobile) │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ FastAPI Application │
|
|
||||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
|
||||||
│ │ Auth API │ │ Home API │ │ Lyric API │ │ Song/Video API │ │
|
|
||||||
│ │ (카카오) │ │ (크롤링) │ │ (가사생성) │ │ (음악/영상 생성) │ │
|
|
||||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
|
|
||||||
└─────────┼────────────────┼────────────────┼─────────────────────┼───────────┘
|
|
||||||
│ │ │ │
|
|
||||||
▼ ▼ ▼ ▼
|
|
||||||
┌─────────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐
|
|
||||||
│ Kakao OAuth │ │ Naver Maps │ │ ChatGPT │ │ External AI Services │
|
|
||||||
│ (로그인) │ │ (크롤링) │ │ (OpenAI) │ │ ┌───────┐ ┌──────────┐ │
|
|
||||||
└─────────────────┘ └─────────────┘ └─────────────┘ │ │ Suno │ │Creatomate│ │
|
|
||||||
│ │ (음악) │ │ (영상) │ │
|
|
||||||
│ └───────┘ └──────────┘ │
|
|
||||||
└─────────────────────────┘
|
|
||||||
│ │ │ │
|
|
||||||
▼ ▼ ▼ ▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Data Layer │
|
|
||||||
│ ┌─────────────┐ ┌─────────────────────┐ │
|
|
||||||
│ │ MySQL │ │ Azure Blob Storage │ │
|
|
||||||
│ │ (메인 DB) │ │ (미디어 저장소) │ │
|
|
||||||
│ └─────────────┘ └─────────────────────┘ │
|
|
||||||
│ ┌─────────────┐ │
|
|
||||||
│ │ Redis │ │
|
|
||||||
│ │ (캐시/세션) │ │
|
|
||||||
│ └─────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 광고 콘텐츠 생성 플로우
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
||||||
│ 1. 입력 │───▶│ 2. 크롤링 │───▶│ 3. 가사 │───▶│ 4. 음악 │───▶│ 5. 영상 │
|
|
||||||
│ │ │ │ │ 생성 │ │ 생성 │ │ 생성 │
|
|
||||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
|
|
||||||
│ │ │ │ │
|
|
||||||
▼ ▼ ▼ ▼ ▼
|
|
||||||
┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
||||||
│장소 URL │ │Naver Maps│ │ ChatGPT │ │ Suno AI │ │Creatomate│
|
|
||||||
│or 이미지 │ │ 크롤러 │ │ API │ │ API │ │ API │
|
|
||||||
└────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
|
|
||||||
│ │ │ │
|
|
||||||
▼ ▼ ▼ ▼
|
|
||||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
||||||
│장소 정보 │ │ 광고 가사 │ │ MP3 │ │ 광고 영상 │
|
|
||||||
│이미지 수집 │ │ 텍스트 │ │ 파일 │ │ 파일 │
|
|
||||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 인증 플로우 (카카오 OAuth)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────┐ ┌────────────┐ ┌───────────┐ ┌────────────┐
|
|
||||||
│ Client │ │ CastAD │ │ Kakao │ │ MySQL │
|
|
||||||
│ │ │ Backend │ │ OAuth │ │ │
|
|
||||||
└───┬────┘ └─────┬──────┘ └─────┬─────┘ └─────┬──────┘
|
|
||||||
│ │ │ │
|
|
||||||
│ 1. 로그인 요청 │ │ │
|
|
||||||
│───────────────▶│ │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ 2. 카카오 URL │ │ │
|
|
||||||
│◀───────────────│ │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ 3. 카카오 로그인 │ │ │
|
|
||||||
│────────────────────────────────▶ │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ 4. 인가 코드 │ │ │
|
|
||||||
│◀──────────────────────────────── │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ 5. 콜백 (code) │ │ │
|
|
||||||
│───────────────▶│ 6. 토큰 요청 │ │
|
|
||||||
│ │─────────────────▶│ │
|
|
||||||
│ │ 7. Access Token │ │
|
|
||||||
│ │◀─────────────────│ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 8. 사용자 저장/조회 │ │
|
|
||||||
│ │─────────────────────────────────▶ │
|
|
||||||
│ │◀───────────────────────────────── │
|
|
||||||
│ │ │ │
|
|
||||||
│ 9. JWT 토큰 발급 │ │ │
|
|
||||||
│◀───────────────│ │ │
|
|
||||||
│ │ │ │
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,38 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from sqladmin import Admin
|
from sqladmin import Admin
|
||||||
|
|
||||||
from app.database.session import engine
|
from app.database.session import engine
|
||||||
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
||||||
from app.lyric.api.lyrics_admin import LyricAdmin
|
from app.lyric.api.lyrics_admin import LyricAdmin
|
||||||
from app.song.api.song_admin import SongAdmin
|
from app.song.api.song_admin import SongAdmin
|
||||||
from app.sns.api.sns_admin import SNSUploadTaskAdmin
|
from app.video.api.video_admin import VideoAdmin
|
||||||
from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
|
from config import prj_settings
|
||||||
from app.video.api.video_admin import VideoAdmin
|
|
||||||
from config import prj_settings
|
# https://github.com/aminalaee/sqladmin
|
||||||
|
|
||||||
# https://github.com/aminalaee/sqladmin
|
|
||||||
|
def init_admin(
|
||||||
|
app: FastAPI,
|
||||||
def init_admin(
|
db_engine: engine,
|
||||||
app: FastAPI,
|
base_url: str = prj_settings.ADMIN_BASE_URL,
|
||||||
db_engine: engine,
|
) -> Admin:
|
||||||
base_url: str = prj_settings.ADMIN_BASE_URL,
|
admin = Admin(
|
||||||
) -> Admin:
|
app,
|
||||||
admin = Admin(
|
db_engine,
|
||||||
app,
|
base_url=base_url,
|
||||||
db_engine,
|
)
|
||||||
base_url=base_url,
|
|
||||||
)
|
# 프로젝트 관리
|
||||||
|
admin.add_view(ProjectAdmin)
|
||||||
# 프로젝트 관리
|
admin.add_view(ImageAdmin)
|
||||||
admin.add_view(ProjectAdmin)
|
|
||||||
admin.add_view(ImageAdmin)
|
# 가사 관리
|
||||||
|
admin.add_view(LyricAdmin)
|
||||||
# 가사 관리
|
|
||||||
admin.add_view(LyricAdmin)
|
# 노래 관리
|
||||||
|
admin.add_view(SongAdmin)
|
||||||
# 노래 관리
|
|
||||||
admin.add_view(SongAdmin)
|
# 영상 관리
|
||||||
|
admin.add_view(VideoAdmin)
|
||||||
# 영상 관리
|
|
||||||
admin.add_view(VideoAdmin)
|
return admin
|
||||||
|
|
||||||
# 사용자 관리
|
|
||||||
admin.add_view(UserAdmin)
|
|
||||||
admin.add_view(RefreshTokenAdmin)
|
|
||||||
admin.add_view(SocialAccountAdmin)
|
|
||||||
|
|
||||||
# SNS 관리
|
|
||||||
admin.add_view(SNSUploadTaskAdmin)
|
|
||||||
|
|
||||||
return admin
|
|
||||||
|
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
"""
|
|
||||||
Archive API 라우터
|
|
||||||
|
|
||||||
사용자의 아카이브(완료된 영상 목록) 관련 엔드포인트를 제공합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.archive.worker.archive_task import soft_delete_by_task_id
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
|
||||||
from app.home.models import Project
|
|
||||||
from app.user.dependencies.auth import get_current_user
|
|
||||||
from app.user.models import User
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from app.utils.pagination import PaginatedResponse
|
|
||||||
from app.video.models import Video
|
|
||||||
from app.video.schemas.video_schema import VideoListItem
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/archive", tags=["Archive"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/videos/",
|
|
||||||
summary="완료된 영상 목록 조회",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
완료된(status='completed') 영상 목록을 페이지네이션하여 반환합니다.
|
|
||||||
|
|
||||||
## 쿼리 파라미터
|
|
||||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
|
||||||
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **items**: 영상 목록 (video_id, store_name, region, task_id, result_movie_url, created_at)
|
|
||||||
- **total**: 전체 데이터 수
|
|
||||||
- **page**: 현재 페이지
|
|
||||||
- **page_size**: 페이지당 데이터 수
|
|
||||||
- **total_pages**: 전체 페이지 수
|
|
||||||
- **has_next**: 다음 페이지 존재 여부
|
|
||||||
- **has_prev**: 이전 페이지 존재 여부
|
|
||||||
|
|
||||||
## 사용 예시
|
|
||||||
```
|
|
||||||
GET /archive/videos/?page=1&page_size=10
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- **본인이 소유한 프로젝트의 영상만 반환됩니다.**
|
|
||||||
- status가 'completed'인 영상만 반환됩니다.
|
|
||||||
- 동일 task_id의 영상이 여러 개인 경우, 가장 최근에 생성된 영상만 반환됩니다.
|
|
||||||
- created_at 기준 내림차순 정렬됩니다.
|
|
||||||
""",
|
|
||||||
response_model=PaginatedResponse[VideoListItem],
|
|
||||||
responses={
|
|
||||||
200: {"description": "영상 목록 조회 성공"},
|
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_videos(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination_params),
|
|
||||||
) -> PaginatedResponse[VideoListItem]:
|
|
||||||
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
|
|
||||||
logger.info(
|
|
||||||
f"[get_videos] START - user: {current_user.user_uuid}, "
|
|
||||||
f"page: {pagination.page}, page_size: {pagination.page_size}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
offset = (pagination.page - 1) * pagination.page_size
|
|
||||||
|
|
||||||
# 서브쿼리: task_id별 최신 Video ID 추출
|
|
||||||
# id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치
|
|
||||||
latest_video_ids = (
|
|
||||||
select(func.max(Video.id).label("latest_id"))
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(
|
|
||||||
Project.user_uuid == current_user.user_uuid,
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
Project.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.group_by(Video.task_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만)
|
|
||||||
count_query = select(func.count(Video.id)).where(
|
|
||||||
Video.id.in_(select(latest_video_ids.c.latest_id))
|
|
||||||
)
|
|
||||||
total_result = await session.execute(count_query)
|
|
||||||
total = total_result.scalar() or 0
|
|
||||||
|
|
||||||
# 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만)
|
|
||||||
data_query = (
|
|
||||||
select(Video, Project)
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
|
|
||||||
.order_by(Video.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(pagination.page_size)
|
|
||||||
)
|
|
||||||
result = await session.execute(data_query)
|
|
||||||
rows = result.all()
|
|
||||||
|
|
||||||
# VideoListItem으로 변환
|
|
||||||
items = [
|
|
||||||
VideoListItem(
|
|
||||||
video_id=video.id,
|
|
||||||
store_name=project.store_name,
|
|
||||||
region=project.region,
|
|
||||||
task_id=video.task_id,
|
|
||||||
result_movie_url=video.result_movie_url,
|
|
||||||
created_at=video.created_at,
|
|
||||||
)
|
|
||||||
for video, project in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=pagination.page,
|
|
||||||
page_size=pagination.page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
|
||||||
logger.error(f"[get_videos] EXCEPTION - error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/videos/{video_id}",
|
|
||||||
summary="개별 영상 소프트 삭제",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
video_id에 해당하는 영상만 소프트 삭제합니다.
|
|
||||||
(is_deleted=True로 설정, 실제 데이터는 DB에 유지)
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **video_id**: 삭제할 영상의 ID (Video.id)
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 본인이 소유한 프로젝트의 영상만 삭제할 수 있습니다.
|
|
||||||
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
|
|
||||||
- 프로젝트나 다른 관련 데이터(Song, Lyric 등)는 삭제되지 않습니다.
|
|
||||||
""",
|
|
||||||
responses={
|
|
||||||
200: {"description": "삭제 성공"},
|
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
403: {"description": "삭제 권한 없음"},
|
|
||||||
404: {"description": "영상을 찾을 수 없음"},
|
|
||||||
500: {"description": "삭제 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def delete_single_video(
|
|
||||||
video_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> dict:
|
|
||||||
"""video_id에 해당하는 개별 영상만 소프트 삭제합니다."""
|
|
||||||
logger.info(f"[delete_single_video] START - video_id: {video_id}, user: {current_user.user_uuid}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Video 조회 (Project와 함께)
|
|
||||||
result = await session.execute(
|
|
||||||
select(Video, Project)
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(
|
|
||||||
Video.id == video_id,
|
|
||||||
Video.is_deleted == False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
row = result.one_or_none()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
logger.warning(f"[delete_single_video] NOT FOUND - video_id: {video_id}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="영상을 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
video, project = row
|
|
||||||
|
|
||||||
# 소유권 검증
|
|
||||||
if project.user_uuid != current_user.user_uuid:
|
|
||||||
logger.warning(
|
|
||||||
f"[delete_single_video] FORBIDDEN - video_id: {video_id}, "
|
|
||||||
f"owner: {project.user_uuid}, requester: {current_user.user_uuid}"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="삭제 권한이 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 소프트 삭제
|
|
||||||
video.is_deleted = True
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"[delete_single_video] SUCCESS - video_id: {video_id}")
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "영상이 삭제되었습니다.",
|
|
||||||
"video_id": video_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[delete_single_video] EXCEPTION - video_id: {video_id}, error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"삭제에 실패했습니다: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/videos/delete/{task_id}",
|
|
||||||
summary="프로젝트 전체 소프트 삭제 (task_id 기준)",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트 삭제합니다.
|
|
||||||
(is_deleted=True로 설정, 실제 데이터는 DB에 유지)
|
|
||||||
|
|
||||||
## 소프트 삭제 대상 테이블
|
|
||||||
1. **Video**: 동일 task_id의 모든 영상
|
|
||||||
2. **SongTimestamp**: 관련 노래의 타임스탬프 (suno_audio_id 기준)
|
|
||||||
3. **Song**: 동일 task_id의 모든 노래
|
|
||||||
4. **Lyric**: 동일 task_id의 모든 가사
|
|
||||||
5. **Image**: 동일 task_id의 모든 이미지
|
|
||||||
6. **Project**: task_id에 해당하는 프로젝트
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **task_id**: 삭제할 프로젝트의 task_id (UUID7 형식)
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 본인이 소유한 프로젝트만 삭제할 수 있습니다.
|
|
||||||
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
|
|
||||||
- 백그라운드에서 비동기로 처리됩니다.
|
|
||||||
- **개별 영상만 삭제하려면 DELETE /archive/videos/{video_id}를 사용하세요.**
|
|
||||||
""",
|
|
||||||
responses={
|
|
||||||
200: {"description": "삭제 요청 성공"},
|
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
403: {"description": "삭제 권한 없음"},
|
|
||||||
404: {"description": "프로젝트를 찾을 수 없음"},
|
|
||||||
500: {"description": "삭제 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def delete_video(
|
|
||||||
task_id: str,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> dict:
|
|
||||||
"""task_id에 해당하는 프로젝트와 관련 데이터를 소프트 삭제합니다."""
|
|
||||||
logger.info(f"[delete_video] START - task_id: {task_id}, user: {current_user.user_uuid}")
|
|
||||||
logger.debug(f"[delete_video] DEBUG - current_user.user_uuid: {current_user.user_uuid}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# DEBUG: task_id로 조회 가능한 모든 Project 확인 (is_deleted 무관)
|
|
||||||
all_projects_result = await session.execute(
|
|
||||||
select(Project).where(Project.task_id == task_id)
|
|
||||||
)
|
|
||||||
all_projects = all_projects_result.scalars().all()
|
|
||||||
logger.debug(
|
|
||||||
f"[delete_video] DEBUG - task_id로 조회된 모든 Project 수: {len(all_projects)}"
|
|
||||||
)
|
|
||||||
for p in all_projects:
|
|
||||||
logger.debug(
|
|
||||||
f"[delete_video] DEBUG - Project: id={p.id}, task_id={p.task_id}, "
|
|
||||||
f"user_uuid={p.user_uuid}, is_deleted={p.is_deleted}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 프로젝트 조회
|
|
||||||
result = await session.execute(
|
|
||||||
select(Project).where(
|
|
||||||
Project.task_id == task_id,
|
|
||||||
Project.is_deleted == False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
project = result.scalar_one_or_none()
|
|
||||||
logger.debug(f"[delete_video] DEBUG - 조회된 Project (is_deleted=False): {project}")
|
|
||||||
|
|
||||||
if project is None:
|
|
||||||
logger.warning(f"[delete_video] NOT FOUND - task_id: {task_id}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="프로젝트를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"[delete_video] DEBUG - Project 상세: id={project.id}, "
|
|
||||||
f"user_uuid={project.user_uuid}, store_name={project.store_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 소유권 검증
|
|
||||||
if project.user_uuid != current_user.user_uuid:
|
|
||||||
logger.warning(
|
|
||||||
f"[delete_video] FORBIDDEN - task_id: {task_id}, "
|
|
||||||
f"owner: {project.user_uuid}, requester: {current_user.user_uuid}"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="삭제 권한이 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# DEBUG: 삭제 대상 데이터 수 미리 확인
|
|
||||||
video_count_result = await session.execute(
|
|
||||||
select(func.count(Video.id)).where(
|
|
||||||
Video.task_id == task_id, Video.is_deleted == False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
video_count = video_count_result.scalar() or 0
|
|
||||||
logger.debug(f"[delete_video] DEBUG - 삭제 대상 Video 수: {video_count}")
|
|
||||||
|
|
||||||
# 백그라운드 태스크로 소프트 삭제 실행
|
|
||||||
background_tasks.add_task(soft_delete_by_task_id, task_id)
|
|
||||||
|
|
||||||
logger.info(f"[delete_video] ACCEPTED - task_id: {task_id}, soft delete scheduled")
|
|
||||||
return {
|
|
||||||
"message": "삭제 요청이 접수되었습니다. 백그라운드에서 처리됩니다.",
|
|
||||||
"task_id": task_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[delete_video] EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"삭제에 실패했습니다: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
"""
|
|
||||||
Archive Worker 모듈
|
|
||||||
|
|
||||||
아카이브 관련 백그라운드 작업을 처리합니다.
|
|
||||||
- 소프트 삭제 (is_deleted=True 설정)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy import func, select, update
|
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
|
||||||
from app.home.models import Image, Project
|
|
||||||
from app.lyric.models import Lyric
|
|
||||||
from app.song.models import Song, SongTimestamp
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def soft_delete_by_task_id(task_id: str) -> dict:
|
|
||||||
"""
|
|
||||||
task_id에 해당하는 모든 관련 데이터를 소프트 삭제합니다.
|
|
||||||
|
|
||||||
대상 테이블 (refresh_token, social_account, user 제외):
|
|
||||||
- Project
|
|
||||||
- Image
|
|
||||||
- Lyric
|
|
||||||
- Song
|
|
||||||
- SongTimestamp (suno_audio_id 기준)
|
|
||||||
- Video
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 삭제할 프로젝트의 task_id
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: 각 테이블별 업데이트된 레코드 수
|
|
||||||
"""
|
|
||||||
logger.info(f"[soft_delete_by_task_id] START - task_id: {task_id}")
|
|
||||||
logger.debug(f"[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작")
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"task_id": task_id,
|
|
||||||
"project": 0,
|
|
||||||
"image": 0,
|
|
||||||
"lyric": 0,
|
|
||||||
"song": 0,
|
|
||||||
"song_timestamp": 0,
|
|
||||||
"video": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
# DEBUG: 삭제 전 각 테이블의 데이터 수 확인
|
|
||||||
video_before = await session.execute(
|
|
||||||
select(func.count(Video.id)).where(
|
|
||||||
Video.task_id == task_id, Video.is_deleted == False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Video 수: {video_before.scalar() or 0}"
|
|
||||||
)
|
|
||||||
|
|
||||||
song_before = await session.execute(
|
|
||||||
select(func.count(Song.id)).where(
|
|
||||||
Song.task_id == task_id, Song.is_deleted == False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Song 수: {song_before.scalar() or 0}"
|
|
||||||
)
|
|
||||||
|
|
||||||
lyric_before = await session.execute(
|
|
||||||
select(func.count(Lyric.id)).where(
|
|
||||||
Lyric.task_id == task_id, Lyric.is_deleted == False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Lyric 수: {lyric_before.scalar() or 0}"
|
|
||||||
)
|
|
||||||
|
|
||||||
image_before = await session.execute(
|
|
||||||
select(func.count(Image.id)).where(
|
|
||||||
Image.task_id == task_id, Image.is_deleted == False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Image 수: {image_before.scalar() or 0}"
|
|
||||||
)
|
|
||||||
|
|
||||||
project_before = await session.execute(
|
|
||||||
select(func.count(Project.id)).where(
|
|
||||||
Project.task_id == task_id, Project.is_deleted == False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Project 수: {project_before.scalar() or 0}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. Video 소프트 삭제
|
|
||||||
video_stmt = (
|
|
||||||
update(Video)
|
|
||||||
.where(Video.task_id == task_id, Video.is_deleted == False)
|
|
||||||
.values(is_deleted=True)
|
|
||||||
)
|
|
||||||
video_result = await session.execute(video_stmt)
|
|
||||||
result["video"] = video_result.rowcount
|
|
||||||
logger.info(f"[soft_delete_by_task_id] Video soft deleted - count: {result['video']}")
|
|
||||||
logger.debug(f"[soft_delete_by_task_id] DEBUG - Video rowcount: {video_result.rowcount}")
|
|
||||||
|
|
||||||
# 2. SongTimestamp 소프트 삭제 (Song의 suno_audio_id 기준, 서브쿼리 사용)
|
|
||||||
suno_subquery = (
|
|
||||||
select(Song.suno_audio_id)
|
|
||||||
.where(
|
|
||||||
Song.task_id == task_id,
|
|
||||||
Song.suno_audio_id.isnot(None),
|
|
||||||
)
|
|
||||||
.scalar_subquery()
|
|
||||||
)
|
|
||||||
timestamp_stmt = (
|
|
||||||
update(SongTimestamp)
|
|
||||||
.where(
|
|
||||||
SongTimestamp.suno_audio_id.in_(suno_subquery),
|
|
||||||
SongTimestamp.is_deleted == False,
|
|
||||||
)
|
|
||||||
.values(is_deleted=True)
|
|
||||||
)
|
|
||||||
timestamp_result = await session.execute(timestamp_stmt)
|
|
||||||
result["song_timestamp"] = timestamp_result.rowcount
|
|
||||||
logger.info(
|
|
||||||
f"[soft_delete_by_task_id] SongTimestamp soft deleted - count: {result['song_timestamp']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Song 소프트 삭제
|
|
||||||
song_stmt = (
|
|
||||||
update(Song)
|
|
||||||
.where(Song.task_id == task_id, Song.is_deleted == False)
|
|
||||||
.values(is_deleted=True)
|
|
||||||
)
|
|
||||||
song_result = await session.execute(song_stmt)
|
|
||||||
result["song"] = song_result.rowcount
|
|
||||||
logger.info(f"[soft_delete_by_task_id] Song soft deleted - count: {result['song']}")
|
|
||||||
|
|
||||||
# 4. Lyric 소프트 삭제
|
|
||||||
lyric_stmt = (
|
|
||||||
update(Lyric)
|
|
||||||
.where(Lyric.task_id == task_id, Lyric.is_deleted == False)
|
|
||||||
.values(is_deleted=True)
|
|
||||||
)
|
|
||||||
lyric_result = await session.execute(lyric_stmt)
|
|
||||||
result["lyric"] = lyric_result.rowcount
|
|
||||||
logger.info(f"[soft_delete_by_task_id] Lyric soft deleted - count: {result['lyric']}")
|
|
||||||
|
|
||||||
# 5. Image 소프트 삭제
|
|
||||||
image_stmt = (
|
|
||||||
update(Image)
|
|
||||||
.where(Image.task_id == task_id, Image.is_deleted == False)
|
|
||||||
.values(is_deleted=True)
|
|
||||||
)
|
|
||||||
image_result = await session.execute(image_stmt)
|
|
||||||
result["image"] = image_result.rowcount
|
|
||||||
logger.info(f"[soft_delete_by_task_id] Image soft deleted - count: {result['image']}")
|
|
||||||
|
|
||||||
# 6. Project 소프트 삭제
|
|
||||||
project_stmt = (
|
|
||||||
update(Project)
|
|
||||||
.where(Project.task_id == task_id, Project.is_deleted == False)
|
|
||||||
.values(is_deleted=True)
|
|
||||||
)
|
|
||||||
project_result = await session.execute(project_stmt)
|
|
||||||
result["project"] = project_result.rowcount
|
|
||||||
logger.info(f"[soft_delete_by_task_id] Project soft deleted - count: {result['project']}")
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[soft_delete_by_task_id] SUCCESS - task_id: {task_id}, "
|
|
||||||
f"deleted: project={result['project']}, image={result['image']}, "
|
|
||||||
f"lyric={result['lyric']}, song={result['song']}, "
|
|
||||||
f"song_timestamp={result['song_timestamp']}, video={result['video']}"
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[soft_delete_by_task_id] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
|
||||||
raise
|
|
||||||
|
|
@ -1,56 +1,43 @@
|
||||||
# app/main.py
|
# app/main.py
|
||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
@asynccontextmanager
|
||||||
logger = get_logger("core")
|
async def lifespan(app: FastAPI):
|
||||||
|
"""FastAPI 애플리케이션 생명주기 관리"""
|
||||||
|
# Startup - 애플리케이션 시작 시
|
||||||
@asynccontextmanager
|
print("Starting up...")
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
"""FastAPI 애플리케이션 생명주기 관리"""
|
try:
|
||||||
# Startup - 애플리케이션 시작 시
|
from config import prj_settings
|
||||||
logger.info("Starting up...")
|
|
||||||
|
# DEBUG 모드일 때만 데이터베이스 테이블 자동 생성
|
||||||
try:
|
if prj_settings.DEBUG:
|
||||||
from config import prj_settings
|
from app.database.session import create_db_tables
|
||||||
|
|
||||||
# DEBUG 모드일 때만 데이터베이스 테이블 자동 생성
|
await create_db_tables()
|
||||||
if prj_settings.DEBUG:
|
print("Database tables created (DEBUG mode)")
|
||||||
from app.database.session import create_db_tables
|
except asyncio.TimeoutError:
|
||||||
|
print("Database initialization timed out")
|
||||||
await create_db_tables()
|
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||||
logger.info("Database tables created (DEBUG mode)")
|
raise
|
||||||
await NvMapPwScraper.initiate_scraper()
|
except Exception as e:
|
||||||
except asyncio.TimeoutError:
|
print(f"Database initialization failed: {e}")
|
||||||
logger.error("Database initialization timed out")
|
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||||
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
raise
|
||||||
raise
|
|
||||||
except Exception as e:
|
yield # 애플리케이션 실행 중
|
||||||
logger.error(f"Database initialization failed: {e}")
|
|
||||||
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
# Shutdown - 애플리케이션 종료 시
|
||||||
raise
|
print("Shutting down...")
|
||||||
|
from app.database.session import engine
|
||||||
yield # 애플리케이션 실행 중
|
|
||||||
|
await engine.dispose()
|
||||||
# Shutdown - 애플리케이션 종료 시
|
print("Database engine disposed")
|
||||||
logger.info("Shutting down...")
|
|
||||||
|
|
||||||
# 공유 HTTP 클라이언트 종료
|
# FastAPI 앱 생성 (lifespan 적용)
|
||||||
from app.utils.creatomate import close_shared_client
|
app = FastAPI(title="CastAD", lifespan=lifespan)
|
||||||
from app.utils.upload_blob_as_request import close_shared_blob_client
|
|
||||||
|
|
||||||
await close_shared_client()
|
|
||||||
await close_shared_blob_client()
|
|
||||||
|
|
||||||
# 데이터베이스 엔진 종료
|
|
||||||
from app.database.session import dispose_engine
|
|
||||||
|
|
||||||
await dispose_engine()
|
|
||||||
|
|
||||||
|
|
||||||
# FastAPI 앱 생성 (lifespan 적용)
|
|
||||||
app = FastAPI(title="CastAD", lifespan=lifespan)
|
|
||||||
|
|
|
||||||
|
|
@ -1,320 +1,114 @@
|
||||||
import traceback
|
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||||
from functools import wraps
|
from fastapi.responses import JSONResponse
|
||||||
from typing import Any, Callable, TypeVar
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response, status
|
class FastShipError(Exception):
|
||||||
from fastapi.responses import JSONResponse
|
"""Base exception for all exceptions in fastship api"""
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
# status_code to be returned for this exception
|
||||||
|
# when it is handled
|
||||||
from app.utils.logger import get_logger
|
status = status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
# 로거 설정
|
|
||||||
logger = get_logger("core")
|
class EntityNotFound(FastShipError):
|
||||||
|
"""Entity not found in database"""
|
||||||
T = TypeVar("T")
|
|
||||||
|
status = status.HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
class FastShipError(Exception):
|
|
||||||
"""Base exception for all exceptions in fastship api"""
|
class BadPassword(FastShipError):
|
||||||
# status_code to be returned for this exception
|
"""Password is not strong enough or invalid"""
|
||||||
# when it is handled
|
|
||||||
status = status.HTTP_400_BAD_REQUEST
|
status = status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
class EntityNotFound(FastShipError):
|
class ClientNotAuthorized(FastShipError):
|
||||||
"""Entity not found in database"""
|
"""Client is not authorized to perform the action"""
|
||||||
|
|
||||||
status = status.HTTP_404_NOT_FOUND
|
status = status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
|
|
||||||
class BadPassword(FastShipError):
|
class ClientNotVerified(FastShipError):
|
||||||
"""Password is not strong enough or invalid"""
|
"""Client is not verified"""
|
||||||
|
|
||||||
status = status.HTTP_400_BAD_REQUEST
|
status = status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
|
|
||||||
class ClientNotAuthorized(FastShipError):
|
class NothingToUpdate(FastShipError):
|
||||||
"""Client is not authorized to perform the action"""
|
"""No data provided to update"""
|
||||||
|
|
||||||
status = status.HTTP_401_UNAUTHORIZED
|
|
||||||
|
class BadCredentials(FastShipError):
|
||||||
|
"""User email or password is incorrect"""
|
||||||
class ClientNotVerified(FastShipError):
|
|
||||||
"""Client is not verified"""
|
status = status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
status = status.HTTP_401_UNAUTHORIZED
|
|
||||||
|
class InvalidToken(FastShipError):
|
||||||
|
"""Access token is invalid or expired"""
|
||||||
class NothingToUpdate(FastShipError):
|
|
||||||
"""No data provided to update"""
|
status = status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
|
|
||||||
class BadCredentials(FastShipError):
|
class DeliveryPartnerNotAvailable(FastShipError):
|
||||||
"""User email or password is incorrect"""
|
"""Delivery partner/s do not service the destination"""
|
||||||
|
|
||||||
status = status.HTTP_401_UNAUTHORIZED
|
status = status.HTTP_406_NOT_ACCEPTABLE
|
||||||
|
|
||||||
|
|
||||||
class InvalidToken(FastShipError):
|
class DeliveryPartnerCapacityExceeded(FastShipError):
|
||||||
"""Access token is invalid or expired"""
|
"""Delivery partner has reached their max handling capacity"""
|
||||||
|
|
||||||
status = status.HTTP_401_UNAUTHORIZED
|
status = status.HTTP_406_NOT_ACCEPTABLE
|
||||||
|
|
||||||
|
|
||||||
class DeliveryPartnerNotAvailable(FastShipError):
|
def _get_handler(status: int, detail: str):
|
||||||
"""Delivery partner/s do not service the destination"""
|
# Define
|
||||||
|
def handler(request: Request, exception: Exception) -> Response:
|
||||||
status = status.HTTP_406_NOT_ACCEPTABLE
|
# DEBUG PRINT STATEMENT 👇
|
||||||
|
from rich import print, panel
|
||||||
|
print(
|
||||||
class DeliveryPartnerCapacityExceeded(FastShipError):
|
panel.Panel(
|
||||||
"""Delivery partner has reached their max handling capacity"""
|
exception.__class__.__name__,
|
||||||
|
title="Handled Exception",
|
||||||
status = status.HTTP_406_NOT_ACCEPTABLE
|
border_style="red",
|
||||||
|
),
|
||||||
|
)
|
||||||
# =============================================================================
|
# DEBUG PRINT STATEMENT 👆
|
||||||
# 데이터베이스 관련 예외
|
|
||||||
# =============================================================================
|
# Raise HTTPException with given status and detail
|
||||||
|
# can return JSONResponse as well
|
||||||
|
raise HTTPException(
|
||||||
class DatabaseError(FastShipError):
|
status_code=status,
|
||||||
"""Database operation failed"""
|
detail=detail,
|
||||||
|
)
|
||||||
status = status.HTTP_503_SERVICE_UNAVAILABLE
|
# Return ExceptionHandler required with given
|
||||||
|
# status and detail for HTTPExcetion above
|
||||||
|
return handler
|
||||||
class DatabaseConnectionError(DatabaseError):
|
|
||||||
"""Database connection failed"""
|
|
||||||
|
def add_exception_handlers(app: FastAPI):
|
||||||
status = status.HTTP_503_SERVICE_UNAVAILABLE
|
# Get all subclass of 👇, our custom exceptions
|
||||||
|
exception_classes = FastShipError.__subclasses__()
|
||||||
|
|
||||||
class DatabaseTimeoutError(DatabaseError):
|
for exception_class in exception_classes:
|
||||||
"""Database operation timed out"""
|
# Add exception handler
|
||||||
|
app.add_exception_handler(
|
||||||
status = status.HTTP_504_GATEWAY_TIMEOUT
|
# Custom exception class
|
||||||
|
exception_class,
|
||||||
|
# Get handler function
|
||||||
# =============================================================================
|
_get_handler(
|
||||||
# 외부 서비스 관련 예외
|
status=exception_class.status,
|
||||||
# =============================================================================
|
detail=exception_class.__doc__,
|
||||||
|
),
|
||||||
|
)
|
||||||
class ExternalServiceError(FastShipError):
|
|
||||||
"""External service call failed"""
|
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
def internal_server_error_handler(request, exception):
|
||||||
status = status.HTTP_502_BAD_GATEWAY
|
return JSONResponse(
|
||||||
|
content={"detail": "Something went wrong..."},
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
class GPTServiceError(ExternalServiceError):
|
headers={
|
||||||
"""GPT API call failed"""
|
"X-Error": f"{exception}",
|
||||||
|
}
|
||||||
status = status.HTTP_502_BAD_GATEWAY
|
)
|
||||||
|
|
||||||
|
|
||||||
class CrawlingError(ExternalServiceError):
|
|
||||||
"""Web crawling failed"""
|
|
||||||
|
|
||||||
status = status.HTTP_502_BAD_GATEWAY
|
|
||||||
|
|
||||||
|
|
||||||
class BlobStorageError(ExternalServiceError):
|
|
||||||
"""Azure Blob Storage operation failed"""
|
|
||||||
|
|
||||||
status = status.HTTP_502_BAD_GATEWAY
|
|
||||||
|
|
||||||
|
|
||||||
class CreatomateError(ExternalServiceError):
|
|
||||||
"""Creatomate API call failed"""
|
|
||||||
|
|
||||||
status = status.HTTP_502_BAD_GATEWAY
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 예외 처리 데코레이터
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def handle_db_exceptions(
|
|
||||||
error_message: str = "데이터베이스 작업 중 오류가 발생했습니다.",
|
|
||||||
):
|
|
||||||
"""데이터베이스 예외를 처리하는 데코레이터.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error_message: 오류 발생 시 반환할 메시지
|
|
||||||
|
|
||||||
Example:
|
|
||||||
@handle_db_exceptions("사용자 조회 중 오류 발생")
|
|
||||||
async def get_user(user_id: int):
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
||||||
@wraps(func)
|
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
try:
|
|
||||||
return await func(*args, **kwargs)
|
|
||||||
except HTTPException:
|
|
||||||
# HTTPException은 그대로 raise
|
|
||||||
raise
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(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.debug(traceback.format_exc())
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="서비스 처리 중 예기치 않은 오류가 발생했습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def handle_external_service_exceptions(
|
|
||||||
service_name: str = "외부 서비스",
|
|
||||||
error_message: str | None = None,
|
|
||||||
):
|
|
||||||
"""외부 서비스 호출 예외를 처리하는 데코레이터.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
service_name: 서비스 이름 (로그용)
|
|
||||||
error_message: 오류 발생 시 반환할 메시지
|
|
||||||
|
|
||||||
Example:
|
|
||||||
@handle_external_service_exceptions("GPT")
|
|
||||||
async def call_gpt():
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
||||||
@wraps(func)
|
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
try:
|
|
||||||
return await func(*args, **kwargs)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
msg = error_message or f"{service_name} 서비스 호출 중 오류가 발생했습니다."
|
|
||||||
logger.error(f"[{service_name} Error] {func.__name__}: {e}")
|
|
||||||
logger.debug(traceback.format_exc())
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
detail=msg,
|
|
||||||
)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def handle_api_exceptions(
|
|
||||||
error_message: str = "요청 처리 중 오류가 발생했습니다.",
|
|
||||||
):
|
|
||||||
"""API 엔드포인트 예외를 처리하는 데코레이터.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error_message: 오류 발생 시 반환할 메시지
|
|
||||||
|
|
||||||
Example:
|
|
||||||
@handle_api_exceptions("가사 생성 중 오류 발생")
|
|
||||||
async def generate_lyric():
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
||||||
@wraps(func)
|
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
try:
|
|
||||||
return await func(*args, **kwargs)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(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.debug(traceback.format_exc())
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=error_message,
|
|
||||||
)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def _get_handler(status: int, detail: str):
|
|
||||||
# Define
|
|
||||||
def handler(request: Request, exception: Exception) -> Response:
|
|
||||||
logger.debug(f"Handled Exception: {exception.__class__.__name__}")
|
|
||||||
|
|
||||||
# Raise HTTPException with given status and detail
|
|
||||||
# can return JSONResponse as well
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status,
|
|
||||||
detail=detail,
|
|
||||||
)
|
|
||||||
# Return ExceptionHandler required with given
|
|
||||||
# status and detail for HTTPExcetion above
|
|
||||||
return handler
|
|
||||||
|
|
||||||
|
|
||||||
def add_exception_handlers(app: FastAPI):
|
|
||||||
# Get all subclass of 👇, our custom exceptions
|
|
||||||
exception_classes = FastShipError.__subclasses__()
|
|
||||||
|
|
||||||
for exception_class in exception_classes:
|
|
||||||
# Add exception handler
|
|
||||||
app.add_exception_handler(
|
|
||||||
# Custom exception class
|
|
||||||
exception_class,
|
|
||||||
# Get handler function
|
|
||||||
_get_handler(
|
|
||||||
status=exception_class.status,
|
|
||||||
detail=exception_class.__doc__,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# SocialException 핸들러 추가
|
|
||||||
from app.social.exceptions import SocialException
|
|
||||||
|
|
||||||
from app.social.exceptions import TokenExpiredError
|
|
||||||
|
|
||||||
@app.exception_handler(SocialException)
|
|
||||||
def social_exception_handler(request: Request, exc: SocialException) -> Response:
|
|
||||||
logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}")
|
|
||||||
content = {
|
|
||||||
"detail": exc.message,
|
|
||||||
"code": exc.code,
|
|
||||||
}
|
|
||||||
# TokenExpiredError인 경우 재연동 정보 추가
|
|
||||||
if isinstance(exc, TokenExpiredError):
|
|
||||||
content["platform"] = exc.platform
|
|
||||||
content["reconnect_url"] = f"/social/oauth/{exc.platform}/connect"
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
def internal_server_error_handler(request, exception):
|
|
||||||
# 에러 메시지 로깅 (한글 포함 가능)
|
|
||||||
logger.error(f"Internal Server Error: {exception}")
|
|
||||||
return JSONResponse(
|
|
||||||
content={"detail": "Something went wrong..."},
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
@ -1,20 +1,30 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from app.config import db_settings
|
from app.config import db_settings
|
||||||
|
|
||||||
|
|
||||||
_shipment_verification_codes = Redis(
|
_token_blacklist = Redis(
|
||||||
host=db_settings.REDIS_HOST,
|
host=db_settings.REDIS_HOST,
|
||||||
port=db_settings.REDIS_PORT,
|
port=db_settings.REDIS_PORT,
|
||||||
db=1,
|
db=0,
|
||||||
decode_responses=True,
|
)
|
||||||
)
|
_shipment_verification_codes = Redis(
|
||||||
|
host=db_settings.REDIS_HOST,
|
||||||
|
port=db_settings.REDIS_PORT,
|
||||||
async def add_shipment_verification_code(id: UUID, code: int):
|
db=1,
|
||||||
await _shipment_verification_codes.set(str(id), code)
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
|
||||||
async def get_shipment_verification_code(id: UUID) -> str:
|
async def add_jti_to_blacklist(jti: str):
|
||||||
return str(await _shipment_verification_codes.get(str(id)))
|
await _token_blacklist.set(jti, "blacklisted")
|
||||||
|
|
||||||
|
|
||||||
|
async def is_jti_blacklisted(jti: str) -> bool:
|
||||||
|
return await _token_blacklist.exists(jti)
|
||||||
|
|
||||||
|
async def add_shipment_verification_code(id: UUID, code: int):
|
||||||
|
await _shipment_verification_codes.set(str(id), code)
|
||||||
|
|
||||||
|
async def get_shipment_verification_code(id: UUID) -> str:
|
||||||
|
return str(await _shipment_verification_codes.get(str(id)))
|
||||||
|
|
@ -1,100 +1,97 @@
|
||||||
from asyncio import current_task
|
from asyncio import current_task
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
create_async_engine,
|
create_async_engine,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
|
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from config import db_settings
|
||||||
from config import db_settings
|
|
||||||
|
|
||||||
logger = get_logger("database")
|
# Base 클래스 정의
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
# Base 클래스 정의
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
engine = create_async_engine(
|
||||||
|
# MySQL async URL (asyncmy 드라이버)
|
||||||
|
url=db_settings.MYSQL_URL, # 예: "mysql+asyncmy://test:test@host:3306/poc"
|
||||||
engine = create_async_engine(
|
# === Connection Pool 설정 ===
|
||||||
# MySQL async URL (asyncmy 드라이버)
|
pool_size=10, # 기본 풀 크기: 10개 연결 유지
|
||||||
url=db_settings.MYSQL_URL, # 예: "mysql+asyncmy://test:test@host:3306/poc"
|
max_overflow=10, # 최대 증가: 10개 (총 20개까지 가능)
|
||||||
# === Connection Pool 설정 ===
|
poolclass=AsyncQueuePool, # 비동기 큐 풀 사용 (기본값, 명시적 지정)
|
||||||
pool_size=10, # 기본 풀 크기: 10개 연결 유지
|
pool_timeout=30, # 풀에서 연결 대기 시간: 30초 (기본 30초)
|
||||||
max_overflow=10, # 최대 증가: 10개 (총 20개까지 가능)
|
pool_recycle=3600, # 연결 재사용 주기: 1시간 (기본 3600초)
|
||||||
poolclass=AsyncQueuePool, # 비동기 큐 풀 사용 (기본값, 명시적 지정)
|
pool_pre_ping=True, # 연결 사용 전 유효성 검사: True로 설정
|
||||||
pool_timeout=30, # 풀에서 연결 대기 시간: 30초 (기본 30초)
|
pool_reset_on_return="rollback", # 연결 반환 시 자동 롤백
|
||||||
pool_recycle=3600, # 연결 재사용 주기: 1시간 (기본 3600초)
|
# === MySQL 특화 설정 ===
|
||||||
pool_pre_ping=True, # 연결 사용 전 유효성 검사: True로 설정
|
echo=False, # SQL 쿼리 로깅 (디버깅 시 True)
|
||||||
pool_reset_on_return="rollback", # 연결 반환 시 자동 롤백
|
# === 연결 타임아웃 및 재시도 ===
|
||||||
# === MySQL 특화 설정 ===
|
connect_args={
|
||||||
echo=False, # SQL 쿼리 로깅 (디버깅 시 True)
|
"connect_timeout": 10, # MySQL 연결 타임아웃: 10초
|
||||||
# === 연결 타임아웃 및 재시도 ===
|
"read_timeout": 30, # 읽기 타임아웃: 30초
|
||||||
connect_args={
|
"write_timeout": 30, # 쓰기 타임아웃: 30초
|
||||||
"connect_timeout": 10, # MySQL 연결 타임아웃: 10초
|
"charset": "utf8mb4", # 문자셋 (이모지 지원)
|
||||||
"read_timeout": 30, # 읽기 타임아웃: 30초
|
"sql_mode": "STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE",
|
||||||
"write_timeout": 30, # 쓰기 타임아웃: 30초
|
"init_command": "SET SESSION time_zone = '+00:00'", # 초기 연결 시 실행
|
||||||
"charset": "utf8mb4", # 문자셋 (이모지 지원)
|
},
|
||||||
"sql_mode": "STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE",
|
)
|
||||||
"init_command": "SET SESSION time_zone = '+00:00'", # 초기 연결 시 실행
|
|
||||||
},
|
# Async 세션 팩토리 생성
|
||||||
)
|
async_session_factory = async_sessionmaker(
|
||||||
|
bind=engine,
|
||||||
# Async 세션 팩토리 생성
|
class_=AsyncSession,
|
||||||
async_session_factory = async_sessionmaker(
|
expire_on_commit=False, # 커밋 후 객체 상태 유지
|
||||||
bind=engine,
|
autoflush=True, # 변경 감지 자동 플러시
|
||||||
class_=AsyncSession,
|
)
|
||||||
expire_on_commit=False, # 커밋 후 객체 상태 유지
|
|
||||||
autoflush=True, # 변경 감지 자동 플러시
|
# async_scoped_session 생성
|
||||||
)
|
AsyncScopedSession = async_session_factory(
|
||||||
|
async_session_factory,
|
||||||
# async_scoped_session 생성
|
scopefunc=current_task,
|
||||||
AsyncScopedSession = async_session_factory(
|
)
|
||||||
async_session_factory,
|
|
||||||
scopefunc=current_task,
|
|
||||||
)
|
# 테이블 생성 함수
|
||||||
|
async def create_db_tables() -> None:
|
||||||
|
async with engine.begin() as conn:
|
||||||
# 테이블 생성 함수
|
# from app.database.models import Shipment, Seller # noqa: F401
|
||||||
async def create_db_tables() -> None:
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
async with engine.begin() as conn:
|
print("MySQL tables created successfully")
|
||||||
# from app.database.models import Shipment, Seller # noqa: F401
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
logger.info("MySQL tables created successfully")
|
# 세션 제너레이터 (FastAPI Depends에 사용)
|
||||||
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""
|
||||||
# 세션 제너레이터 (FastAPI Depends에 사용)
|
Async 세션 컨텍스트 매니저
|
||||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
- FastAPI dependency로 사용
|
||||||
"""
|
- Connection Pool에서 연결 획득/반환 자동 관리
|
||||||
Async 세션 컨텍스트 매니저
|
"""
|
||||||
- FastAPI dependency로 사용
|
async with async_session_factory() as session:
|
||||||
- Connection Pool에서 연결 획득/반환 자동 관리
|
# pre-commit 훅 (선택적: 트랜잭션 시작 전 실행)
|
||||||
"""
|
# await session.begin() # async_sessionmaker에서 자동 begin
|
||||||
async with async_session_factory() as session:
|
|
||||||
# pre-commit 훅 (선택적: 트랜잭션 시작 전 실행)
|
try:
|
||||||
# await session.begin() # async_sessionmaker에서 자동 begin
|
yield session
|
||||||
|
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
|
||||||
try:
|
except Exception as e:
|
||||||
yield session
|
await session.rollback() # 명시적 롤백 (선택적)
|
||||||
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
|
print(f"Session rollback due to: {e}") # 로깅
|
||||||
except Exception as e:
|
raise
|
||||||
await session.rollback() # 명시적 롤백 (선택적)
|
finally:
|
||||||
logger.error(f"Session rollback due to: {e}")
|
# 명시적 세션 종료 (Connection Pool에 반환)
|
||||||
raise
|
# context manager가 자동 처리하지만, 명시적으로 유지
|
||||||
finally:
|
await session.close()
|
||||||
# 명시적 세션 종료 (Connection Pool에 반환)
|
print("session closed successfully")
|
||||||
# context manager가 자동 처리하지만, 명시적으로 유지
|
# 또는 session.aclose() - Python 3.10+
|
||||||
await session.close()
|
|
||||||
logger.debug("session closed successfully")
|
|
||||||
# 또는 session.aclose() - Python 3.10+
|
# 애플리케이션 종료 시 엔진 정리 (선택적)
|
||||||
|
async def dispose_engine() -> None:
|
||||||
|
"""애플리케이션 종료 시 모든 연결 해제"""
|
||||||
# 애플리케이션 종료 시 엔진 정리 (선택적)
|
await engine.dispose()
|
||||||
async def dispose_engine() -> None:
|
print("Database engine disposed")
|
||||||
"""애플리케이션 종료 시 모든 연결 해제"""
|
|
||||||
await engine.dispose()
|
|
||||||
logger.info("Database engine disposed")
|
|
||||||
|
|
|
||||||
|
|
@ -1,187 +1,127 @@
|
||||||
import time
|
from contextlib import asynccontextmanager
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from config import db_settings
|
from config import db_settings
|
||||||
|
|
||||||
logger = get_logger("database")
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
# 데이터베이스 엔진 생성
|
||||||
|
engine = create_async_engine(
|
||||||
# =============================================================================
|
url=db_settings.MYSQL_URL,
|
||||||
# 메인 엔진 (FastAPI 요청용)
|
echo=False,
|
||||||
# =============================================================================
|
pool_size=10,
|
||||||
engine = create_async_engine(
|
max_overflow=10,
|
||||||
url=db_settings.MYSQL_URL,
|
pool_timeout=5,
|
||||||
echo=False,
|
pool_recycle=3600,
|
||||||
pool_size=20, # 기본 풀 크기: 20
|
pool_pre_ping=True,
|
||||||
max_overflow=20, # 추가 연결: 20 (총 최대 40)
|
pool_reset_on_return="rollback",
|
||||||
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
|
connect_args={
|
||||||
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
|
"connect_timeout": 3,
|
||||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
"charset": "utf8mb4",
|
||||||
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
|
# "allow_public_key_retrieval": True,
|
||||||
connect_args={
|
},
|
||||||
"connect_timeout": 10, # DB 연결 타임아웃
|
)
|
||||||
"charset": "utf8mb4",
|
|
||||||
},
|
# Async sessionmaker 생성
|
||||||
)
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
bind=engine,
|
||||||
# 메인 세션 팩토리 (FastAPI DI용)
|
class_=AsyncSession,
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
expire_on_commit=False,
|
||||||
bind=engine,
|
autoflush=False, # 명시적 flush 권장
|
||||||
class_=AsyncSession,
|
)
|
||||||
expire_on_commit=False,
|
|
||||||
autoflush=False, # 명시적 flush 권장
|
|
||||||
)
|
async def create_db_tables():
|
||||||
|
import asyncio
|
||||||
|
|
||||||
# =============================================================================
|
# 모델 import (테이블 메타데이터 등록용)
|
||||||
# 백그라운드 태스크 전용 엔진 (메인 풀과 분리)
|
from app.home.models import Image, Project # noqa: F401
|
||||||
# =============================================================================
|
from app.lyric.models import Lyric # noqa: F401
|
||||||
background_engine = create_async_engine(
|
from app.song.models import Song # noqa: F401
|
||||||
url=db_settings.MYSQL_URL,
|
from app.video.models import Video # noqa: F401
|
||||||
echo=False,
|
|
||||||
pool_size=10, # 백그라운드용 풀 크기: 10
|
print("Creating database tables...")
|
||||||
max_overflow=10, # 추가 연결: 10 (총 최대 20)
|
|
||||||
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
|
async with asyncio.timeout(10):
|
||||||
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
async with engine.begin() as connection:
|
||||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
pool_reset_on_return="rollback",
|
|
||||||
connect_args={
|
|
||||||
"connect_timeout": 10,
|
# FastAPI 의존성용 세션 제너레이터
|
||||||
"charset": "utf8mb4",
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
},
|
async with AsyncSessionLocal() as session:
|
||||||
)
|
try:
|
||||||
|
yield session
|
||||||
# 백그라운드 세션 팩토리
|
# print("Session commited")
|
||||||
BackgroundSessionLocal = async_sessionmaker(
|
# await session.commit()
|
||||||
bind=background_engine,
|
except Exception as e:
|
||||||
class_=AsyncSession,
|
await session.rollback()
|
||||||
expire_on_commit=False,
|
print(f"Session rollback due to: {e}")
|
||||||
autoflush=False,
|
raise e
|
||||||
)
|
# async with 종료 시 session.close()가 자동 호출됨
|
||||||
|
|
||||||
|
|
||||||
async def create_db_tables():
|
# 앱 종료 시 엔진 리소스 정리 함수
|
||||||
import asyncio
|
async def dispose_engine() -> None:
|
||||||
|
await engine.dispose()
|
||||||
# 모델 import (테이블 메타데이터 등록용)
|
print("Database engine disposed")
|
||||||
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
|
|
||||||
from app.home.models import Image, Project, MarketingIntel # noqa: F401
|
|
||||||
from app.lyric.models import Lyric # noqa: F401
|
# =============================================================================
|
||||||
from app.song.models import Song, SongTimestamp # noqa: F401
|
# 백그라운드 태스크용 세션 (별도 이벤트 루프에서 사용)
|
||||||
from app.video.models import Video # noqa: F401
|
# =============================================================================
|
||||||
from app.sns.models import SNSUploadTask # noqa: F401
|
|
||||||
from app.social.models import SocialUpload # noqa: F401
|
|
||||||
|
@asynccontextmanager
|
||||||
# 생성할 테이블 목록
|
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
tables_to_create = [
|
"""백그라운드 태스크용 세션 컨텍스트 매니저
|
||||||
User.__table__,
|
|
||||||
RefreshToken.__table__,
|
asyncio.run()으로 새 이벤트 루프를 생성하는 백그라운드 태스크에서 사용합니다.
|
||||||
SocialAccount.__table__,
|
NullPool을 사용하여 연결 풀링을 비활성화하고, 이벤트 루프 충돌을 방지합니다.
|
||||||
Project.__table__,
|
|
||||||
Image.__table__,
|
get_session()과의 차이점:
|
||||||
Lyric.__table__,
|
- get_session(): FastAPI DI용, 메인 이벤트 루프의 연결 풀 사용
|
||||||
Song.__table__,
|
- get_worker_session(): 백그라운드 태스크용, NullPool로 매번 새 연결 생성
|
||||||
SongTimestamp.__table__,
|
|
||||||
Video.__table__,
|
Usage:
|
||||||
SNSUploadTask.__table__,
|
async with get_worker_session() as session:
|
||||||
SocialUpload.__table__,
|
result = await session.execute(select(Model))
|
||||||
MarketingIntel.__table__,
|
await session.commit()
|
||||||
]
|
|
||||||
|
Note:
|
||||||
logger.info("Creating database tables...")
|
- 매 호출마다 엔진을 생성하고 dispose하므로 오버헤드가 있음
|
||||||
|
- 빈번한 호출이 필요한 경우 방법 1(모듈 레벨 엔진)을 고려
|
||||||
async with asyncio.timeout(10):
|
"""
|
||||||
async with engine.begin() as connection:
|
worker_engine = create_async_engine(
|
||||||
await connection.run_sync(
|
url=db_settings.MYSQL_URL,
|
||||||
lambda conn: Base.metadata.create_all(conn, tables=tables_to_create)
|
poolclass=NullPool,
|
||||||
)
|
connect_args={
|
||||||
|
"connect_timeout": 3,
|
||||||
|
"charset": "utf8mb4",
|
||||||
# FastAPI 의존성용 세션 제너레이터
|
},
|
||||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
)
|
||||||
start_time = time.perf_counter()
|
session_factory = async_sessionmaker(
|
||||||
pool = engine.pool
|
bind=worker_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
# 커넥션 풀 상태 로깅 (디버깅용)
|
expire_on_commit=False,
|
||||||
# logger.debug(
|
autoflush=False,
|
||||||
# f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
)
|
||||||
# f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
|
||||||
# f"overflow: {pool.overflow()}"
|
async with session_factory() as session:
|
||||||
# )
|
try:
|
||||||
|
yield session
|
||||||
async with AsyncSessionLocal() as session:
|
except Exception as e:
|
||||||
acquire_time = time.perf_counter()
|
await session.rollback()
|
||||||
# logger.debug(
|
print(f"Worker session rollback due to: {e}")
|
||||||
# f"[get_session] Session acquired in "
|
raise e
|
||||||
# f"{(acquire_time - start_time)*1000:.1f}ms"
|
finally:
|
||||||
# )
|
await session.close()
|
||||||
try:
|
|
||||||
yield session
|
await worker_engine.dispose()
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
await session.rollback()
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
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
|
|
||||||
# logger.debug(
|
|
||||||
# f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
|
|
||||||
# f"pool_out: {pool.checkedout()}"
|
|
||||||
# )
|
|
||||||
|
|
||||||
|
|
||||||
# 백그라운드 태스크용 세션 제너레이터
|
|
||||||
async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
start_time = time.perf_counter()
|
|
||||||
pool = background_engine.pool
|
|
||||||
|
|
||||||
# logger.debug(
|
|
||||||
# f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
|
|
||||||
# f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
|
||||||
# f"overflow: {pool.overflow()}"
|
|
||||||
# )
|
|
||||||
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
acquire_time = time.perf_counter()
|
|
||||||
# logger.debug(
|
|
||||||
# f"[get_background_session] Session acquired in "
|
|
||||||
# f"{(acquire_time - start_time)*1000:.1f}ms"
|
|
||||||
# )
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
logger.error(
|
|
||||||
f"[get_background_session] ROLLBACK - "
|
|
||||||
f"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
|
|
||||||
# logger.debug(
|
|
||||||
# f"[get_background_session] RELEASE - "
|
|
||||||
# f"duration: {total_time*1000:.1f}ms, "
|
|
||||||
# f"pool_out: {pool.checkedout()}"
|
|
||||||
# )
|
|
||||||
|
|
||||||
|
|
||||||
# 앱 종료 시 엔진 리소스 정리 함수
|
|
||||||
async def dispose_engine() -> None:
|
|
||||||
logger.info("[dispose_engine] Disposing database engines...")
|
|
||||||
await engine.dispose()
|
|
||||||
logger.info("[dispose_engine] Main engine disposed")
|
|
||||||
await background_engine.dispose()
|
|
||||||
logger.info("[dispose_engine] Background engine disposed - ALL DONE")
|
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,102 @@
|
||||||
from sqladmin import ModelView
|
from sqladmin import ModelView
|
||||||
|
|
||||||
from app.home.models import Image, Project
|
from app.home.models import Image, Project
|
||||||
|
|
||||||
|
|
||||||
class ProjectAdmin(ModelView, model=Project):
|
class ProjectAdmin(ModelView, model=Project):
|
||||||
name = "프로젝트"
|
name = "프로젝트"
|
||||||
name_plural = "프로젝트 목록"
|
name_plural = "프로젝트 목록"
|
||||||
icon = "fa-solid fa-folder"
|
icon = "fa-solid fa-folder"
|
||||||
category = "프로젝트 관리"
|
category = "프로젝트 관리"
|
||||||
page_size = 20
|
page_size = 20
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"store_name",
|
"store_name",
|
||||||
"region",
|
"region",
|
||||||
"task_id",
|
"task_id",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"store_name",
|
"store_name",
|
||||||
"region",
|
"region",
|
||||||
"task_id",
|
"task_id",
|
||||||
"detail_region_info",
|
"detail_region_info",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 폼(생성/수정)에서 제외
|
# 폼(생성/수정)에서 제외
|
||||||
form_excluded_columns = ["created_at", "lyrics", "songs", "videos"]
|
form_excluded_columns = ["created_at", "lyrics", "songs", "videos"]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
Project.store_name,
|
Project.store_name,
|
||||||
Project.region,
|
Project.region,
|
||||||
Project.task_id,
|
Project.task_id,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_default_sort = (Project.created_at, True) # True: DESC (최신순)
|
column_default_sort = (Project.created_at, True) # True: DESC (최신순)
|
||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
Project.id,
|
Project.id,
|
||||||
Project.store_name,
|
Project.store_name,
|
||||||
Project.region,
|
Project.region,
|
||||||
Project.created_at,
|
Project.created_at,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"store_name": "가게명",
|
"store_name": "가게명",
|
||||||
"region": "지역",
|
"region": "지역",
|
||||||
"task_id": "작업 ID",
|
"task_id": "작업 ID",
|
||||||
"detail_region_info": "상세 지역 정보",
|
"detail_region_info": "상세 지역 정보",
|
||||||
"created_at": "생성일시",
|
"created_at": "생성일시",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ImageAdmin(ModelView, model=Image):
|
class ImageAdmin(ModelView, model=Image):
|
||||||
name = "이미지"
|
name = "이미지"
|
||||||
name_plural = "이미지 목록"
|
name_plural = "이미지 목록"
|
||||||
icon = "fa-solid fa-image"
|
icon = "fa-solid fa-image"
|
||||||
category = "프로젝트 관리"
|
category = "프로젝트 관리"
|
||||||
page_size = 20
|
page_size = 20
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"img_name",
|
"img_name",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"img_name",
|
"img_name",
|
||||||
"img_url",
|
"img_url",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 폼(생성/수정)에서 제외
|
# 폼(생성/수정)에서 제외
|
||||||
form_excluded_columns = ["created_at"]
|
form_excluded_columns = ["created_at"]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
Image.task_id,
|
Image.task_id,
|
||||||
Image.img_name,
|
Image.img_name,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_default_sort = (Image.created_at, True) # True: DESC (최신순)
|
column_default_sort = (Image.created_at, True) # True: DESC (최신순)
|
||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
Image.id,
|
Image.id,
|
||||||
Image.img_name,
|
Image.img_name,
|
||||||
Image.created_at,
|
Image.created_at,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"task_id": "작업 ID",
|
"task_id": "작업 ID",
|
||||||
"img_name": "이미지명",
|
"img_name": "이미지명",
|
||||||
"img_url": "이미지 URL",
|
"img_url": "이미지 URL",
|
||||||
"created_at": "생성일시",
|
"created_at": "생성일시",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,15 @@
|
||||||
"""
|
"""API 1 Version Router Module."""
|
||||||
Home API v1 라우터 모듈
|
|
||||||
"""
|
# from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
# API 버전 1 라우터를 정의합니다.
|
||||||
|
# router = APIRouter(
|
||||||
|
# prefix="/api/v1",
|
||||||
|
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
||||||
|
# )
|
||||||
|
# router = APIRouter(
|
||||||
|
# prefix="/api/v1",
|
||||||
|
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
||||||
|
# )
|
||||||
|
# router.include_router(auth.router, tags=[Tags.AUTH])
|
||||||
|
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD])
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,320 +1,215 @@
|
||||||
"""
|
"""
|
||||||
Home 모듈 SQLAlchemy 모델 정의
|
Home 모듈 SQLAlchemy 모델 정의
|
||||||
|
|
||||||
이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
|
이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
|
||||||
- Project: 프로젝트(사용자 입력 이력) 관리
|
- Project: 프로젝트(사용자 입력 이력) 관리
|
||||||
- Image: 업로드된 이미지 URL 관리
|
- Image: 업로드된 이미지 URL 관리
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional, Any
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, JSON, func
|
from sqlalchemy import DateTime, Index, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
from app.user.models import User
|
from app.video.models import Video
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
class Project(Base):
|
"""
|
||||||
"""
|
프로젝트 테이블 (사용자 입력 이력)
|
||||||
프로젝트 테이블 (사용자 입력 이력)
|
|
||||||
|
영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다.
|
||||||
영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다.
|
하위 테이블(Lyric, Song, Video)의 부모 테이블 역할을 합니다.
|
||||||
하위 테이블(Lyric, Song, Video)의 부모 테이블 역할을 합니다.
|
|
||||||
|
Attributes:
|
||||||
Attributes:
|
id: 고유 식별자 (자동 증가)
|
||||||
id: 고유 식별자 (자동 증가)
|
store_name: 고객명 (필수)
|
||||||
store_name: 고객명 (필수)
|
region: 지역명 (필수, 예: 서울, 부산, 대구 등)
|
||||||
region: 지역명 (필수, 예: 서울, 부산, 대구 등)
|
task_id: 작업 고유 식별자 (UUID 형식, 36자)
|
||||||
task_id: 작업 고유 식별자 (UUID7 형식, 36자)
|
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
|
||||||
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
|
created_at: 생성 일시 (자동 설정)
|
||||||
created_at: 생성 일시 (자동 설정)
|
|
||||||
|
Relationships:
|
||||||
Relationships:
|
lyrics: 생성된 가사 목록
|
||||||
owner: 프로젝트 소유자 (User, 1:N 관계)
|
songs: 생성된 노래 목록
|
||||||
lyrics: 생성된 가사 목록
|
videos: 최종 영상 결과 목록
|
||||||
songs: 생성된 노래 목록
|
"""
|
||||||
videos: 최종 영상 결과 목록
|
|
||||||
"""
|
__tablename__ = "project"
|
||||||
|
__table_args__ = (
|
||||||
__tablename__ = "project"
|
Index("idx_project_task_id", "task_id"),
|
||||||
__table_args__ = (
|
Index("idx_project_store_name", "store_name"),
|
||||||
Index("idx_project_task_id", "task_id"),
|
Index("idx_project_region", "region"),
|
||||||
Index("idx_project_store_name", "store_name"),
|
{
|
||||||
Index("idx_project_region", "region"),
|
"mysql_engine": "InnoDB",
|
||||||
Index("idx_project_user_uuid", "user_uuid"),
|
"mysql_charset": "utf8mb4",
|
||||||
Index("idx_project_is_deleted", "is_deleted"),
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
{
|
},
|
||||||
"mysql_engine": "InnoDB",
|
)
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
id: Mapped[int] = mapped_column(
|
||||||
},
|
Integer,
|
||||||
)
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
id: Mapped[int] = mapped_column(
|
autoincrement=True,
|
||||||
Integer,
|
comment="고유 식별자",
|
||||||
primary_key=True,
|
)
|
||||||
nullable=False,
|
|
||||||
autoincrement=True,
|
store_name: Mapped[str] = mapped_column(
|
||||||
comment="고유 식별자",
|
String(255),
|
||||||
)
|
nullable=False,
|
||||||
|
index=True,
|
||||||
store_name: Mapped[str] = mapped_column(
|
comment="가게명",
|
||||||
String(255),
|
)
|
||||||
nullable=False,
|
|
||||||
comment="가게명",
|
region: Mapped[str] = mapped_column(
|
||||||
)
|
String(100),
|
||||||
|
nullable=False,
|
||||||
region: Mapped[str] = mapped_column(
|
index=True,
|
||||||
String(100),
|
comment="지역명 (예: 군산)",
|
||||||
nullable=False,
|
)
|
||||||
comment="지역명 (예: 군산)",
|
|
||||||
)
|
task_id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
task_id: Mapped[str] = mapped_column(
|
nullable=False,
|
||||||
String(36),
|
comment="프로젝트 작업 고유 식별자 (UUID)",
|
||||||
nullable=False,
|
)
|
||||||
unique=True,
|
|
||||||
comment="프로젝트 작업 고유 식별자 (UUID7)",
|
detail_region_info: Mapped[Optional[str]] = mapped_column(
|
||||||
)
|
Text,
|
||||||
|
nullable=True,
|
||||||
# ==========================================================================
|
comment="상세 지역 정보",
|
||||||
# User 1:N 관계 (한 사용자가 여러 프로젝트를 소유)
|
)
|
||||||
# ==========================================================================
|
|
||||||
user_uuid: Mapped[Optional[str]] = mapped_column(
|
language: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(50),
|
||||||
ForeignKey("user.user_uuid", ondelete="SET NULL"),
|
nullable=False,
|
||||||
nullable=True,
|
default="Korean",
|
||||||
comment="프로젝트 소유자 (User.user_uuid 외래키)",
|
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 소유자 관계 설정 (User.projects와 양방향 연결)
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
owner: Mapped[Optional["User"]] = relationship(
|
DateTime,
|
||||||
"User",
|
nullable=False,
|
||||||
back_populates="projects",
|
server_default=func.now(),
|
||||||
lazy="selectin",
|
comment="생성 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
detail_region_info: Mapped[Optional[str]] = mapped_column(
|
# Relationships
|
||||||
Text,
|
lyrics: Mapped[List["Lyric"]] = relationship(
|
||||||
nullable=True,
|
"Lyric",
|
||||||
comment="상세 지역 정보",
|
back_populates="project",
|
||||||
)
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
marketing_intelligence: Mapped[Optional[str]] = mapped_column(
|
)
|
||||||
Integer,
|
|
||||||
nullable=True,
|
songs: Mapped[List["Song"]] = relationship(
|
||||||
comment="마케팅 인텔리전스 결과 정보 저장",
|
"Song",
|
||||||
)
|
back_populates="project",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
language: Mapped[str] = mapped_column(
|
lazy="selectin",
|
||||||
String(50),
|
)
|
||||||
nullable=False,
|
|
||||||
default="Korean",
|
videos: Mapped[List["Video"]] = relationship(
|
||||||
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
"Video",
|
||||||
)
|
back_populates="project",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
lazy="selectin",
|
||||||
Boolean,
|
)
|
||||||
nullable=False,
|
|
||||||
default=False,
|
def __repr__(self) -> str:
|
||||||
comment="소프트 삭제 여부 (True: 삭제됨)",
|
def truncate(value: str | None, max_len: int = 10) -> str:
|
||||||
)
|
if value is None:
|
||||||
|
return "None"
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
return (value[:max_len] + "...") if len(value) > max_len else value
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
return (
|
||||||
server_default=func.now(),
|
f"<Project("
|
||||||
comment="생성 일시",
|
f"id={self.id}, "
|
||||||
)
|
f"store_name='{self.store_name}', "
|
||||||
|
f"task_id='{truncate(self.task_id)}'"
|
||||||
# Relationships
|
f")>"
|
||||||
lyrics: Mapped[List["Lyric"]] = relationship(
|
)
|
||||||
"Lyric",
|
|
||||||
back_populates="project",
|
|
||||||
cascade="all, delete-orphan",
|
class Image(Base):
|
||||||
lazy="selectin",
|
"""
|
||||||
)
|
업로드 이미지 테이블
|
||||||
|
|
||||||
songs: Mapped[List["Song"]] = relationship(
|
사용자가 업로드한 이미지의 URL을 저장합니다.
|
||||||
"Song",
|
독립적으로 관리되며 Project와 직접적인 관계가 없습니다.
|
||||||
back_populates="project",
|
|
||||||
cascade="all, delete-orphan",
|
Attributes:
|
||||||
lazy="selectin",
|
id: 고유 식별자 (자동 증가)
|
||||||
)
|
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
|
||||||
|
img_name: 이미지명
|
||||||
videos: Mapped[List["Video"]] = relationship(
|
img_url: 이미지 URL (S3, CDN 등의 경로)
|
||||||
"Video",
|
created_at: 생성 일시 (자동 설정)
|
||||||
back_populates="project",
|
"""
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="selectin",
|
__tablename__ = "image"
|
||||||
)
|
__table_args__ = (
|
||||||
|
{
|
||||||
def __repr__(self) -> str:
|
"mysql_engine": "InnoDB",
|
||||||
def truncate(value: str | None, max_len: int = 10) -> str:
|
"mysql_charset": "utf8mb4",
|
||||||
if value is None:
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
return "None"
|
},
|
||||||
return (value[:max_len] + "...") if len(value) > max_len else value
|
)
|
||||||
|
|
||||||
return (
|
id: Mapped[int] = mapped_column(
|
||||||
f"<Project("
|
Integer,
|
||||||
f"id={self.id}, "
|
primary_key=True,
|
||||||
f"store_name='{self.store_name}', "
|
nullable=False,
|
||||||
f"task_id='{truncate(self.task_id)}'"
|
autoincrement=True,
|
||||||
f")>"
|
comment="고유 식별자",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
task_id: Mapped[str] = mapped_column(
|
||||||
class Image(Base):
|
String(36),
|
||||||
"""
|
nullable=False,
|
||||||
업로드 이미지 테이블
|
comment="이미지 업로드 작업 고유 식별자 (UUID)",
|
||||||
|
)
|
||||||
사용자가 업로드한 이미지의 URL을 저장합니다.
|
|
||||||
독립적으로 관리되며 Project와 직접적인 관계가 없습니다.
|
img_name: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
Attributes:
|
nullable=False,
|
||||||
id: 고유 식별자 (자동 증가)
|
comment="이미지명",
|
||||||
task_id: 이미지 업로드 작업 고유 식별자 (UUID7)
|
)
|
||||||
img_name: 이미지명
|
|
||||||
img_url: 이미지 URL (S3, CDN 등의 경로)
|
img_url: Mapped[str] = mapped_column(
|
||||||
created_at: 생성 일시 (자동 설정)
|
String(2048),
|
||||||
"""
|
nullable=False,
|
||||||
|
comment="이미지 URL (blob, CDN 경로)",
|
||||||
__tablename__ = "image"
|
)
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_image_task_id", "task_id"),
|
img_order: Mapped[int] = mapped_column(
|
||||||
Index("idx_image_is_deleted", "is_deleted"),
|
Integer,
|
||||||
{
|
nullable=False,
|
||||||
"mysql_engine": "InnoDB",
|
default=0,
|
||||||
"mysql_charset": "utf8mb4",
|
comment="이미지 순서",
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
)
|
||||||
},
|
|
||||||
)
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
id: Mapped[int] = mapped_column(
|
nullable=False,
|
||||||
Integer,
|
server_default=func.now(),
|
||||||
primary_key=True,
|
comment="생성 일시",
|
||||||
nullable=False,
|
)
|
||||||
autoincrement=True,
|
|
||||||
comment="고유 식별자",
|
def __repr__(self) -> str:
|
||||||
)
|
task_id_str = (
|
||||||
|
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
|
||||||
task_id: Mapped[str] = mapped_column(
|
)
|
||||||
String(36),
|
img_name_str = (
|
||||||
nullable=False,
|
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
|
||||||
comment="이미지 업로드 작업 고유 식별자 (UUID7)",
|
)
|
||||||
)
|
|
||||||
|
return (
|
||||||
img_name: Mapped[str] = mapped_column(
|
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
||||||
String(255),
|
)
|
||||||
nullable=False,
|
|
||||||
comment="이미지명",
|
|
||||||
)
|
|
||||||
|
|
||||||
img_url: Mapped[str] = mapped_column(
|
|
||||||
String(2048),
|
|
||||||
nullable=False,
|
|
||||||
comment="이미지 URL (blob, CDN 경로)",
|
|
||||||
)
|
|
||||||
|
|
||||||
img_order: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
default=0,
|
|
||||||
comment="이미지 순서",
|
|
||||||
)
|
|
||||||
|
|
||||||
is_deleted: 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="생성 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
task_id_str = (
|
|
||||||
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
|
|
||||||
)
|
|
||||||
img_name_str = (
|
|
||||||
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MarketingIntel(Base):
|
|
||||||
"""
|
|
||||||
마케팅 인텔리전스 결과물 테이블
|
|
||||||
|
|
||||||
마케팅 분석 결과물 저장합니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: 고유 식별자 (자동 증가)
|
|
||||||
place_id : 데이터 소스별 식별자
|
|
||||||
intel_result : 마케팅 분석 결과물 json
|
|
||||||
created_at: 생성 일시 (자동 설정)
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "marketing"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_place_id", "place_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="고유 식별자",
|
|
||||||
)
|
|
||||||
|
|
||||||
place_id: Mapped[str] = mapped_column(
|
|
||||||
String(36),
|
|
||||||
nullable=False,
|
|
||||||
comment="매장 소스별 고유 식별자",
|
|
||||||
)
|
|
||||||
|
|
||||||
intel_result : Mapped[dict[str, Any]] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=False,
|
|
||||||
comment="마케팅 인텔리전스 결과물",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="생성 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
task_id_str = (
|
|
||||||
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
|
|
||||||
)
|
|
||||||
img_name_str = (
|
|
||||||
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AttributeInfo(BaseModel):
|
||||||
|
"""음악 속성 정보"""
|
||||||
|
|
||||||
|
genre: str = Field(..., description="음악 장르")
|
||||||
|
vocal: str = Field(..., description="보컬 스타일")
|
||||||
|
tempo: str = Field(..., description="템포")
|
||||||
|
mood: str = Field(..., description="분위기")
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequestImg(BaseModel):
|
||||||
|
"""이미지 URL 스키마"""
|
||||||
|
|
||||||
|
url: str = Field(..., description="이미지 URL")
|
||||||
|
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequestInfo(BaseModel):
|
||||||
|
"""생성 요청 정보 스키마 (이미지 제외)"""
|
||||||
|
|
||||||
|
customer_name: str = Field(..., description="고객명/가게명")
|
||||||
|
region: str = Field(..., description="지역명")
|
||||||
|
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||||
|
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
|
||||||
|
language: str = Field(
|
||||||
|
default="Korean",
|
||||||
|
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequest(GenerateRequestInfo):
|
||||||
|
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
|
||||||
|
|
||||||
|
이미지 없이 프로젝트 정보만 전달합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"customer_name": "스테이 머뭄",
|
||||||
|
"region": "군산",
|
||||||
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
|
"attribute": {
|
||||||
|
"genre": "K-Pop",
|
||||||
|
"vocal": "Raspy",
|
||||||
|
"tempo": "110 BPM",
|
||||||
|
"mood": "happy",
|
||||||
|
},
|
||||||
|
"language": "Korean",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateUrlsRequest(GenerateRequestInfo):
|
||||||
|
"""URL 기반 생성 요청 스키마 (JSON body)
|
||||||
|
|
||||||
|
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"customer_name": "스테이 머뭄",
|
||||||
|
"region": "군산",
|
||||||
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
|
"attribute": {
|
||||||
|
"genre": "K-Pop",
|
||||||
|
"vocal": "Raspy",
|
||||||
|
"tempo": "110 BPM",
|
||||||
|
"mood": "happy",
|
||||||
|
},
|
||||||
|
"language": "Korean",
|
||||||
|
"images": [
|
||||||
|
{"url": "https://example.com/images/image_001.jpg"},
|
||||||
|
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
images: list[GenerateRequestImg] = Field(
|
||||||
|
..., description="이미지 URL 목록", min_length=1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateUploadResponse(BaseModel):
|
||||||
|
"""파일 업로드 기반 생성 응답 스키마"""
|
||||||
|
|
||||||
|
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||||
|
status: Literal["processing", "completed", "failed"] = Field(
|
||||||
|
..., description="작업 상태"
|
||||||
|
)
|
||||||
|
message: str = Field(..., description="응답 메시지")
|
||||||
|
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateResponse(BaseModel):
|
||||||
|
"""생성 응답 스키마"""
|
||||||
|
|
||||||
|
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||||
|
status: Literal["processing", "completed", "failed"] = Field(
|
||||||
|
..., description="작업 상태"
|
||||||
|
)
|
||||||
|
message: str = Field(..., description="응답 메시지")
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlingRequest(BaseModel):
|
||||||
|
"""크롤링 요청 스키마"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
url: str = Field(..., description="네이버 지도 장소 URL")
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedInfo(BaseModel):
|
||||||
|
"""가공된 장소 정보 스키마"""
|
||||||
|
|
||||||
|
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
||||||
|
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
||||||
|
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
||||||
|
|
||||||
|
|
||||||
|
class MarketingAnalysis(BaseModel):
|
||||||
|
"""마케팅 분석 결과 스키마"""
|
||||||
|
|
||||||
|
report: str = Field(..., description="마케팅 분석 리포트")
|
||||||
|
tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
|
||||||
|
facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlingResponse(BaseModel):
|
||||||
|
"""크롤링 응답 스키마"""
|
||||||
|
|
||||||
|
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
||||||
|
image_count: int = Field(..., description="이미지 개수")
|
||||||
|
processed_info: Optional[ProcessedInfo] = Field(
|
||||||
|
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
||||||
|
)
|
||||||
|
marketing_analysis: Optional[MarketingAnalysis] = Field(
|
||||||
|
None, description="마케팅 분석 결과 (report, tags, facilities)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""에러 응답 스키마"""
|
||||||
|
|
||||||
|
success: bool = Field(default=False, description="요청 성공 여부")
|
||||||
|
error_code: str = Field(..., description="에러 코드")
|
||||||
|
message: str = Field(..., description="에러 메시지")
|
||||||
|
detail: Optional[str] = Field(None, description="상세 에러 정보")
|
||||||
|
|
@ -1,329 +0,0 @@
|
||||||
from typing import Literal, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
from app.utils.prompts.schemas import MarketingPromptOutput
|
|
||||||
|
|
||||||
class CrawlingRequest(BaseModel):
|
|
||||||
"""크롤링 요청 스키마"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
url: str = Field(..., description="네이버 지도 장소 URL")
|
|
||||||
|
|
||||||
class AutoCompleteRequest(BaseModel):
|
|
||||||
"""자동완성 요청 스키마"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
'title': '<b>스테이</b>,<b>머뭄</b>',
|
|
||||||
'address': '전북특별자치도 군산시 신흥동 63-18',
|
|
||||||
'roadAddress': '전북특별자치도 군산시 절골길 18',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
title: str = Field(..., description="네이버 검색 place API Title")
|
|
||||||
address: str = Field(..., description="네이버 검색 place API 지번주소")
|
|
||||||
roadAddress: Optional[str] = Field(None, description="네이버 검색 place API 도로명주소")
|
|
||||||
|
|
||||||
|
|
||||||
class AccommodationSearchItem(BaseModel):
|
|
||||||
"""숙박 검색 결과 아이템"""
|
|
||||||
|
|
||||||
title: str = Field(..., description="숙소명 (HTML 태그 포함 가능)")
|
|
||||||
address: str = Field(..., description="지번 주소")
|
|
||||||
roadAddress: str = Field(default="", description="도로명 주소")
|
|
||||||
|
|
||||||
|
|
||||||
class AccommodationSearchResponse(BaseModel):
|
|
||||||
"""숙박 자동완성 검색 응답"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"query": "스테이 머뭄",
|
|
||||||
"count": 2,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "<b>스테이</b>,<b>머뭄</b>",
|
|
||||||
"address": "전북특별자치도 군산시 신흥동 63-18",
|
|
||||||
"roadAddress": "전북특별자치도 군산시 절골길 18",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "머뭄<b>스테이</b>",
|
|
||||||
"address": "전북특별자치도 군산시 비응도동 123",
|
|
||||||
"roadAddress": "전북특별자치도 군산시 비응로 456",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
query: str = Field(..., description="검색어")
|
|
||||||
count: int = Field(..., description="검색 결과 수")
|
|
||||||
items: list[AccommodationSearchItem] = Field(
|
|
||||||
default_factory=list, description="검색 결과 목록"
|
|
||||||
)
|
|
||||||
|
|
||||||
class ProcessedInfo(BaseModel):
|
|
||||||
"""가공된 장소 정보 스키마"""
|
|
||||||
|
|
||||||
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
|
||||||
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
|
||||||
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
|
||||||
|
|
||||||
|
|
||||||
# class MarketingAnalysisDetail(BaseModel):
|
|
||||||
# detail_title : str = Field(..., description="디테일 카테고리 이름")
|
|
||||||
# detail_description : str = Field(..., description="해당 항목 설명")
|
|
||||||
|
|
||||||
# class MarketingAnalysisReport(BaseModel):
|
|
||||||
# """마케팅 분석 리포트 스키마"""
|
|
||||||
# summary : str = Field(..., description="비즈니스 한 줄 요약")
|
|
||||||
# details : list[MarketingAnalysisDetail] = Field(default_factory=list, description="개별 디테일")
|
|
||||||
|
|
||||||
# class MarketingAnalysis(BaseModel):
|
|
||||||
# """마케팅 분석 결과 스키마"""
|
|
||||||
|
|
||||||
# # report: MarketingAnalysisReport = Field(..., description="마케팅 분석 리포트")
|
|
||||||
# tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
|
|
||||||
# selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
|
||||||
|
|
||||||
|
|
||||||
class CrawlingResponse(BaseModel):
|
|
||||||
"""크롤링 응답 스키마"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"status": "completed",
|
|
||||||
"image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"],
|
|
||||||
"image_count": 2,
|
|
||||||
"processed_info": {
|
|
||||||
"customer_name": "스테이 머뭄",
|
|
||||||
"region": "군산",
|
|
||||||
"detail_region_info": "전북특별자치도 군산시 절골길 18"
|
|
||||||
},
|
|
||||||
"marketing_analysis": {
|
|
||||||
"brand_identity": {
|
|
||||||
"location_feature_analysis": "전북 군산시 절골길 일대는 도시의 편의성과 근교의 한적함을 동시에 누릴 수 있어 ‘조용한 재충전’ 수요에 적합합니다. 군산의 레트로 감성과 주변 관광 동선 결합이 쉬워 1~2박 체류형 여행지로 매력적입니다.",
|
|
||||||
"concept_scalability": "‘머뭄’이라는 네이밍을 ‘잠시 멈춰 머무는 시간’으로 확장해, 느린 체크인·명상/독서 큐레이션·로컬 티/다과 등 체류 경험형 서비스로 고도화가 가능합니다. 로컬 콘텐츠(군산 빵/커피, 근대문화 투어)와 결합해 패키지화하면 재방문 명분을 만들 수 있습니다."
|
|
||||||
},
|
|
||||||
"market_positioning": {
|
|
||||||
"category_definition": "군산 감성 ‘슬로우 스테이’ 프라이빗 숙소",
|
|
||||||
"core_value": "아무것도 하지 않아도 회복되는 ‘멈춤의 시간’"
|
|
||||||
},
|
|
||||||
"target_persona": [
|
|
||||||
{
|
|
||||||
"persona": "번아웃 회복형 직장인 커플: 주말에 조용히 쉬며 리셋을 원하는 2인 여행자",
|
|
||||||
"age": {
|
|
||||||
"min_age": 27,
|
|
||||||
"max_age": 39
|
|
||||||
},
|
|
||||||
"favor_target": [
|
|
||||||
"조용한 동네 분위기",
|
|
||||||
"미니멀/내추럴 인테리어",
|
|
||||||
"편안한 침구와 숙면 환경",
|
|
||||||
"셀프 체크인 선호",
|
|
||||||
"카페·맛집 연계 동선"
|
|
||||||
],
|
|
||||||
"decision_trigger": "‘조용히 쉬는 데 최적화’된 프라이빗함과 숙면 컨디션(침구/동선/소음 차단) 확신"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"persona": "감성 기록형 친구 여행: 사진과 무드를 위해 공간을 선택하는 2~3인 여성 그룹",
|
|
||||||
"age": {
|
|
||||||
"min_age": 23,
|
|
||||||
"max_age": 34
|
|
||||||
},
|
|
||||||
"favor_target": [
|
|
||||||
"자연광 좋은 공간",
|
|
||||||
"감성 소품/컬러 톤",
|
|
||||||
"포토존(거울/창가/테이블)",
|
|
||||||
"와인·디저트 페어링",
|
|
||||||
"야간 무드 조명"
|
|
||||||
],
|
|
||||||
"decision_trigger": "사진이 ‘그대로 작품’이 되는 포토 스팟과 야간 무드 연출 요소"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"persona": "로컬 탐험형 소도시 여행자: 군산의 레트로/로컬 콘텐츠를 깊게 즐기는 커플·솔로",
|
|
||||||
"age": {
|
|
||||||
"min_age": 28,
|
|
||||||
"max_age": 45
|
|
||||||
},
|
|
||||||
"favor_target": [
|
|
||||||
"근대문화/레트로 감성",
|
|
||||||
"로컬 맛집·빵집 투어",
|
|
||||||
"동선 효율(차로 이동 용이)",
|
|
||||||
"체크아웃 후 관광 연계",
|
|
||||||
"조용한 밤"
|
|
||||||
],
|
|
||||||
"decision_trigger": "‘군산 로컬 동선’과 결합하기 좋은 위치 + 숙소 자체의 휴식 완성도"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"selling_points": [
|
|
||||||
{
|
|
||||||
"english_category": "LOCATION",
|
|
||||||
"korean_category": "입지 환경",
|
|
||||||
"description": "군산 감성 동선",
|
|
||||||
"score": 88
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"english_category": "HEALING",
|
|
||||||
"korean_category": "힐링 요소",
|
|
||||||
"description": "멈춤이 되는 쉼",
|
|
||||||
"score": 92
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"english_category": "PRIVACY",
|
|
||||||
"korean_category": "프라이버시",
|
|
||||||
"description": "방해 없는 머뭄",
|
|
||||||
"score": 86
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"english_category": "NIGHT MOOD",
|
|
||||||
"korean_category": "야간 감성",
|
|
||||||
"description": "밤이 예쁜 조명",
|
|
||||||
"score": 84
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"english_category": "PHOTO SPOT",
|
|
||||||
"korean_category": "포토 스팟",
|
|
||||||
"description": "자연광 포토존",
|
|
||||||
"score": 83
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"english_category": "SHORT GETAWAY",
|
|
||||||
"korean_category": "숏브레이크",
|
|
||||||
"description": "주말 리셋 스테이",
|
|
||||||
"score": 89
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"english_category": "HOSPITALITY",
|
|
||||||
"korean_category": "서비스",
|
|
||||||
"description": "세심한 웰컴감",
|
|
||||||
"score": 80
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"target_keywords": [
|
|
||||||
"군산숙소",
|
|
||||||
"군산감성숙소",
|
|
||||||
"전북숙소추천",
|
|
||||||
"군산여행",
|
|
||||||
"커플스테이",
|
|
||||||
"주말여행",
|
|
||||||
"감성스테이",
|
|
||||||
"조용한숙소",
|
|
||||||
"힐링스테이",
|
|
||||||
"스테이머뭄"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"m_id" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
status: str = Field(
|
|
||||||
default="completed",
|
|
||||||
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
|
|
||||||
)
|
|
||||||
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
|
||||||
image_count: int = Field(..., description="이미지 개수")
|
|
||||||
processed_info: Optional[ProcessedInfo] = Field(
|
|
||||||
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
|
||||||
)
|
|
||||||
marketing_analysis: Optional[MarketingPromptOutput] = Field(
|
|
||||||
None, description="마케팅 분석 결과 . 실패 시 null"
|
|
||||||
)
|
|
||||||
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
|
||||||
"""에러 응답 스키마"""
|
|
||||||
|
|
||||||
success: bool = Field(default=False, description="요청 성공 여부")
|
|
||||||
error_code: str = Field(..., description="에러 코드")
|
|
||||||
message: str = Field(..., description="에러 메시지")
|
|
||||||
detail: Optional[str] = Field(None, description="상세 에러 정보")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Image Upload Schemas
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class ImageUrlItem(BaseModel):
|
|
||||||
"""이미지 URL 아이템 스키마"""
|
|
||||||
|
|
||||||
url: str = Field(..., description="외부 이미지 URL")
|
|
||||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadResultItem(BaseModel):
|
|
||||||
"""업로드된 이미지 결과 아이템"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="이미지 ID")
|
|
||||||
img_name: str = Field(..., description="이미지명")
|
|
||||||
img_url: str = Field(..., description="이미지 URL")
|
|
||||||
img_order: int = Field(..., description="이미지 순서")
|
|
||||||
source: Literal["url", "file", "blob"] = Field(
|
|
||||||
..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadResponse(BaseModel):
|
|
||||||
"""이미지 업로드 응답 스키마"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"total_count": 3,
|
|
||||||
"url_count": 2,
|
|
||||||
"file_count": 1,
|
|
||||||
"saved_count": 3,
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"img_name": "외관",
|
|
||||||
"img_url": "https://example.com/images/image_001.jpg",
|
|
||||||
"img_order": 0,
|
|
||||||
"source": "url",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"img_name": "내부",
|
|
||||||
"img_url": "https://example.com/images/image_002.jpg",
|
|
||||||
"img_order": 1,
|
|
||||||
"source": "url",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"img_name": "uploaded_image.jpg",
|
|
||||||
"img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
|
||||||
"img_order": 2,
|
|
||||||
"source": "file",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"image_urls": [
|
|
||||||
"https://example.com/images/image_001.jpg",
|
|
||||||
"https://example.com/images/image_002.jpg",
|
|
||||||
"/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)")
|
|
||||||
total_count: int = Field(..., description="총 업로드된 이미지 개수")
|
|
||||||
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
|
|
||||||
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
|
|
||||||
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
|
|
||||||
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
|
|
||||||
image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
class BaseService:
|
class BaseService:
|
||||||
def __init__(self, model, session: AsyncSession):
|
def __init__(self, model, session: AsyncSession):
|
||||||
self.model = model
|
self.model = model
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
async def _get(self, id: UUID):
|
async def _get(self, id: UUID):
|
||||||
return await self.session.get(self.model, id)
|
return await self.session.get(self.model, id)
|
||||||
|
|
||||||
async def _add(self, entity):
|
async def _add(self, entity):
|
||||||
self.session.add(entity)
|
self.session.add(entity)
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
await self.session.refresh(entity)
|
await self.session.refresh(entity)
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
async def _update(self, entity):
|
async def _update(self, entity):
|
||||||
return await self._add(entity)
|
return await self._add(entity)
|
||||||
|
|
||||||
async def _delete(self, entity):
|
async def _delete(self, entity):
|
||||||
await self.session.delete(entity)
|
await self.session.delete(entity)
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
"""
|
|
||||||
네이버 지역 검색 API 클라이언트
|
|
||||||
|
|
||||||
숙박/펜션 자동완성 검색 기능을 제공합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
from config import naver_api_settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class NaverSearchClient:
|
|
||||||
"""
|
|
||||||
네이버 지역 검색 API 클라이언트
|
|
||||||
|
|
||||||
숙박/펜션 카테고리 검색을 위한 클라이언트입니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.client_id = naver_api_settings.NAVER_CLIENT_ID
|
|
||||||
self.client_secret = naver_api_settings.NAVER_CLIENT_SECRET
|
|
||||||
self.api_url = naver_api_settings.NAVER_LOCAL_API_URL
|
|
||||||
|
|
||||||
async def search_accommodation(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
display: int = 5,
|
|
||||||
) -> List[dict]:
|
|
||||||
"""
|
|
||||||
숙박/펜션 검색
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: 검색어
|
|
||||||
display: 결과 개수 (기본 5개)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
검색 결과 리스트 (address, roadAddress, title)
|
|
||||||
"""
|
|
||||||
# 숙박/펜션 카테고리 검색을 위해 쿼리에 키워드 추가
|
|
||||||
search_query = f"{query} 숙박"
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"X-Naver-Client-Id": self.client_id,
|
|
||||||
"X-Naver-Client-Secret": self.client_secret,
|
|
||||||
}
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"query": search_query,
|
|
||||||
"display": display,
|
|
||||||
"sort": "random", # 정확도순
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"[NAVER] 지역 검색 요청 - query: {search_query}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(
|
|
||||||
self.api_url,
|
|
||||||
headers=headers,
|
|
||||||
params=params,
|
|
||||||
) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
error_text = await response.text()
|
|
||||||
logger.error(
|
|
||||||
f"[NAVER] API 오류 - status: {response.status}, error: {error_text}"
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
data = await response.json()
|
|
||||||
items = data.get("items", [])
|
|
||||||
|
|
||||||
# 필요한 필드만 추출
|
|
||||||
results = [
|
|
||||||
{
|
|
||||||
"address": item.get("address", ""),
|
|
||||||
"roadAddress": item.get("roadAddress", ""),
|
|
||||||
"title": item.get("title", ""),
|
|
||||||
}
|
|
||||||
for item in items
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.info(f"[NAVER] 검색 완료 - 결과 수: {len(results)}")
|
|
||||||
return results
|
|
||||||
|
|
||||||
except aiohttp.ClientError as e:
|
|
||||||
logger.error(f"[NAVER] 네트워크 오류 - {str(e)}")
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[NAVER] 예기치 않은 오류 - {str(e)}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
# 싱글톤 인스턴스
|
|
||||||
naver_search_client = NaverSearchClient()
|
|
||||||
|
|
@ -1,48 +1,48 @@
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
from config import db_settings
|
from config import db_settings
|
||||||
|
|
||||||
# 테스트 전용 DB URL
|
# 테스트 전용 DB URL
|
||||||
TEST_DB_URL = db_settings.MYSQL_URL.replace(
|
TEST_DB_URL = db_settings.MYSQL_URL.replace(
|
||||||
f"/{db_settings.MYSQL_DB}",
|
f"/{db_settings.MYSQL_DB}",
|
||||||
"/test_db", # 별도 테스트 DB 사용
|
"/test_db", # 별도 테스트 DB 사용
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def test_engine():
|
async def test_engine():
|
||||||
"""각 테스트마다 생성되는 테스트 엔진"""
|
"""각 테스트마다 생성되는 테스트 엔진"""
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
TEST_DB_URL,
|
TEST_DB_URL,
|
||||||
poolclass=NullPool, # 테스트에서는 풀 비활성화
|
poolclass=NullPool, # 테스트에서는 풀 비활성화
|
||||||
echo=True, # SQL 쿼리 로깅
|
echo=True, # SQL 쿼리 로깅
|
||||||
)
|
)
|
||||||
|
|
||||||
# 테스트 테이블 생성
|
# 테스트 테이블 생성
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
yield engine
|
yield engine
|
||||||
|
|
||||||
# 테스트 테이블 삭제
|
# 테스트 테이블 삭제
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""각 테스트마다 새로운 세션 (격리 보장)"""
|
"""각 테스트마다 새로운 세션 (격리 보장)"""
|
||||||
async_session = async_sessionmaker(
|
async_session = async_sessionmaker(
|
||||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||||
)
|
)
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
yield session
|
yield session
|
||||||
await session.rollback() # 테스트 후 롤백
|
await session.rollback() # 테스트 후 롤백
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_database_connection(test_engine):
|
async def test_database_connection(test_engine):
|
||||||
"""테스트 엔진을 사용한 연결 테스트"""
|
"""테스트 엔진을 사용한 연결 테스트"""
|
||||||
async with test_engine.begin() as connection:
|
async with test_engine.begin() as connection:
|
||||||
result = await connection.execute(text("SELECT 1"))
|
result = await connection.execute(text("SELECT 1"))
|
||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_usage(db_session):
|
async def test_session_usage(db_session):
|
||||||
"""세션을 사용한 테스트"""
|
"""세션을 사용한 테스트"""
|
||||||
result = await db_session.execute(text("SELECT 1 as num"))
|
result = await db_session.execute(text("SELECT 1 as num"))
|
||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,30 @@
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.database.session import AsyncSessionLocal, engine
|
from app.database.session import AsyncSessionLocal, engine
|
||||||
from app.utils.logger import get_logger
|
|
||||||
|
|
||||||
# 로거 설정
|
@pytest.mark.asyncio
|
||||||
logger = get_logger("test_db")
|
async def test_database_connection():
|
||||||
|
"""데이터베이스 연결 테스트"""
|
||||||
|
async with engine.begin() as connection:
|
||||||
@pytest.mark.asyncio
|
result = await connection.execute(text("SELECT 1"))
|
||||||
async def test_database_connection():
|
assert result.scalar() == 1
|
||||||
"""데이터베이스 연결 테스트"""
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
result = await connection.execute(text("SELECT 1"))
|
@pytest.mark.asyncio
|
||||||
assert result.scalar() == 1
|
async def test_session_creation():
|
||||||
|
"""세션 생성 테스트"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
@pytest.mark.asyncio
|
result = await session.execute(text("SELECT 1"))
|
||||||
async def test_session_creation():
|
assert result.scalar() == 1
|
||||||
"""세션 생성 테스트"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await session.execute(text("SELECT 1"))
|
@pytest.mark.asyncio
|
||||||
assert result.scalar() == 1
|
async def test_database_version():
|
||||||
|
"""MySQL 버전 확인 테스트"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
@pytest.mark.asyncio
|
result = await session.execute(text("SELECT VERSION()"))
|
||||||
async def test_database_version():
|
version = result.scalar()
|
||||||
"""MySQL 버전 확인 테스트"""
|
assert version is not None
|
||||||
async with AsyncSessionLocal() as session:
|
print(f"MySQL Version: {version}")
|
||||||
result = await session.execute(text("SELECT VERSION()"))
|
|
||||||
version = result.scalar()
|
|
||||||
assert version is not None
|
|
||||||
logger.info(f"MySQL Version: {version}")
|
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
"""
|
|
||||||
Home Worker 모듈
|
|
||||||
|
|
||||||
이미지 업로드 관련 백그라운드 작업을 처리합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
from fastapi import UploadFile
|
|
||||||
|
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
|
||||||
|
|
||||||
|
|
||||||
async def save_upload_file(file: UploadFile, save_path: Path) -> None:
|
|
||||||
"""업로드 파일을 지정된 경로에 저장"""
|
|
||||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
async with aiofiles.open(save_path, "wb") as f:
|
|
||||||
content = await file.read()
|
|
||||||
await f.write(content)
|
|
||||||
|
|
||||||
|
|
||||||
async def upload_image_to_blob(
|
|
||||||
task_id: str,
|
|
||||||
user_uuid: str,
|
|
||||||
file: UploadFile,
|
|
||||||
filename: str,
|
|
||||||
save_dir: Path,
|
|
||||||
) -> tuple[bool, str, str]:
|
|
||||||
"""
|
|
||||||
이미지 파일을 media에 저장하고 Azure Blob Storage에 업로드합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 작업 고유 식별자
|
|
||||||
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
|
||||||
file: 업로드할 파일 객체
|
|
||||||
filename: 저장될 파일명
|
|
||||||
save_dir: media 저장 디렉토리 경로
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple[bool, str, str]: (업로드 성공 여부, blob_url 또는 에러 메시지, media_path)
|
|
||||||
"""
|
|
||||||
save_path = save_dir / filename
|
|
||||||
media_path = str(save_path)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. media에 파일 저장
|
|
||||||
await save_upload_file(file, save_path)
|
|
||||||
|
|
||||||
# 2. Azure Blob Storage에 업로드
|
|
||||||
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
|
|
||||||
upload_success = await uploader.upload_image(file_path=str(save_path))
|
|
||||||
|
|
||||||
if upload_success:
|
|
||||||
return True, uploader.public_url, media_path
|
|
||||||
else:
|
|
||||||
return False, f"Failed to upload {filename} to Blob", media_path
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return False, str(e), media_path
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database.session import get_worker_session
|
||||||
|
from app.home.schemas.home import GenerateRequest
|
||||||
|
from app.lyric.models import Lyric
|
||||||
|
from app.utils.chatgpt_prompt import ChatgptService
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_lyric(task_id: str, project_id: int, lyric_prompt: str) -> int:
|
||||||
|
"""Lyric 레코드를 DB에 저장 (status=processing, lyric_result=null)"""
|
||||||
|
async with get_worker_session() as session:
|
||||||
|
lyric = Lyric(
|
||||||
|
task_id=task_id,
|
||||||
|
project_id=project_id,
|
||||||
|
status="processing",
|
||||||
|
lyric_prompt=lyric_prompt,
|
||||||
|
lyric_result=None,
|
||||||
|
)
|
||||||
|
session.add(lyric)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(lyric)
|
||||||
|
print(f"Lyric saved: id={lyric.id}, task_id={task_id}, status=processing")
|
||||||
|
return lyric.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_lyric_status(lyric_id: int, status: str, lyric_result: str | None = None) -> None:
|
||||||
|
"""Lyric 레코드의 status와 lyric_result를 업데이트"""
|
||||||
|
async with get_worker_session() as session:
|
||||||
|
result = await session.execute(select(Lyric).where(Lyric.id == lyric_id))
|
||||||
|
lyric = result.scalar_one_or_none()
|
||||||
|
if lyric:
|
||||||
|
lyric.status = status
|
||||||
|
if lyric_result is not None:
|
||||||
|
lyric.lyric_result = lyric_result
|
||||||
|
await session.commit()
|
||||||
|
print(f"Lyric updated: id={lyric_id}, status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
async def lyric_task(
|
||||||
|
task_id: str,
|
||||||
|
project_id: int,
|
||||||
|
customer_name: str,
|
||||||
|
region: str,
|
||||||
|
detail_region_info: str,
|
||||||
|
language: str = "Korean",
|
||||||
|
) -> None:
|
||||||
|
"""가사 생성 작업: ChatGPT로 가사 생성 및 Lyric 테이블 저장/업데이트"""
|
||||||
|
service = ChatgptService(
|
||||||
|
customer_name=customer_name,
|
||||||
|
region=region,
|
||||||
|
detail_region_info=detail_region_info,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lyric 레코드 저장 (status=processing, lyric_result=null)
|
||||||
|
lyric_prompt = service.build_lyrics_prompt()
|
||||||
|
lyric_id = await _save_lyric(task_id, project_id, lyric_prompt)
|
||||||
|
|
||||||
|
# GPT 호출
|
||||||
|
result = await service.generate(prompt=lyric_prompt)
|
||||||
|
|
||||||
|
print(f"GPT Response:\n{result}")
|
||||||
|
|
||||||
|
# 결과에 ERROR가 포함되어 있으면 status를 failed로 업데이트
|
||||||
|
if "ERROR:" in result:
|
||||||
|
await _update_lyric_status(lyric_id, "failed", lyric_result=result)
|
||||||
|
else:
|
||||||
|
await _update_lyric_status(lyric_id, "completed", lyric_result=result)
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_process_async(request_body: GenerateRequest, task_id: str, project_id: int) -> None:
|
||||||
|
"""백그라운드 작업 처리 (async 버전)"""
|
||||||
|
customer_name = request_body.customer_name
|
||||||
|
region = request_body.region
|
||||||
|
detail_region_info = request_body.detail_region_info or ""
|
||||||
|
language = request_body.language
|
||||||
|
|
||||||
|
print(f"customer_name: {customer_name}")
|
||||||
|
print(f"region: {region}")
|
||||||
|
print(f"detail_region_info: {detail_region_info}")
|
||||||
|
print(f"language: {language}")
|
||||||
|
|
||||||
|
# 가사 생성 작업
|
||||||
|
await lyric_task(task_id, project_id, customer_name, region, detail_region_info, language)
|
||||||
|
|
||||||
|
|
||||||
|
def task_process(request_body: GenerateRequest, task_id: str, project_id: int) -> None:
|
||||||
|
"""백그라운드 작업 처리 함수 (sync wrapper)"""
|
||||||
|
asyncio.run(_task_process_async(request_body, task_id, project_id))
|
||||||
|
|
@ -1,61 +1,61 @@
|
||||||
from sqladmin import ModelView
|
from sqladmin import ModelView
|
||||||
|
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
|
|
||||||
|
|
||||||
class LyricAdmin(ModelView, model=Lyric):
|
class LyricAdmin(ModelView, model=Lyric):
|
||||||
name = "가사"
|
name = "가사"
|
||||||
name_plural = "가사 목록"
|
name_plural = "가사 목록"
|
||||||
icon = "fa-solid fa-music"
|
icon = "fa-solid fa-music"
|
||||||
category = "가사 관리"
|
category = "가사 관리"
|
||||||
page_size = 20
|
page_size = 20
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"project_id",
|
"project_id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"status",
|
"status",
|
||||||
"language",
|
"language",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"project_id",
|
"project_id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"status",
|
"status",
|
||||||
"language",
|
"language",
|
||||||
"lyric_prompt",
|
"lyric_prompt",
|
||||||
"lyric_result",
|
"lyric_result",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 폼(생성/수정)에서 제외
|
# 폼(생성/수정)에서 제외
|
||||||
form_excluded_columns = ["created_at", "songs", "videos"]
|
form_excluded_columns = ["created_at", "songs", "videos"]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
Lyric.task_id,
|
Lyric.task_id,
|
||||||
Lyric.status,
|
Lyric.status,
|
||||||
Lyric.language,
|
Lyric.language,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_default_sort = (Lyric.created_at, True) # True: DESC (최신순)
|
column_default_sort = (Lyric.created_at, True) # True: DESC (최신순)
|
||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
Lyric.id,
|
Lyric.id,
|
||||||
Lyric.project_id,
|
Lyric.project_id,
|
||||||
Lyric.status,
|
Lyric.status,
|
||||||
Lyric.language,
|
Lyric.language,
|
||||||
Lyric.created_at,
|
Lyric.created_at,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"project_id": "프로젝트 ID",
|
"project_id": "프로젝트 ID",
|
||||||
"task_id": "작업 ID",
|
"task_id": "작업 ID",
|
||||||
"status": "상태",
|
"status": "상태",
|
||||||
"language": "언어",
|
"language": "언어",
|
||||||
"lyric_prompt": "프롬프트",
|
"lyric_prompt": "프롬프트",
|
||||||
"lyric_result": "생성 결과",
|
"lyric_result": "생성 결과",
|
||||||
"created_at": "생성일시",
|
"created_at": "생성일시",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
"""
|
|
||||||
Lyric API v1 라우터 모듈
|
|
||||||
"""
|
|
||||||
|
|
@ -1,537 +1,445 @@
|
||||||
"""
|
"""
|
||||||
Lyric API Router
|
Lyric API Router
|
||||||
|
|
||||||
이 모듈은 가사 관련 API 엔드포인트를 정의합니다.
|
이 모듈은 가사 관련 API 엔드포인트를 정의합니다.
|
||||||
모든 엔드포인트는 재사용 가능하도록 설계되었습니다.
|
모든 엔드포인트는 재사용 가능하도록 설계되었습니다.
|
||||||
|
|
||||||
엔드포인트 목록:
|
엔드포인트 목록:
|
||||||
- POST /lyric/generate: 가사 생성
|
- POST /lyric/generate: 가사 생성
|
||||||
- GET /lyric/status/{task_id}: 가사 생성 상태 조회
|
- GET /lyric/status/{task_id}: 가사 생성 상태 조회
|
||||||
- GET /lyric/{task_id}: 가사 상세 조회
|
- GET /lyric/{task_id}: 가사 상세 조회
|
||||||
- GET /lyric/list: 가사 목록 조회 (페이지네이션)
|
- GET /lyrics: 가사 목록 조회 (페이지네이션)
|
||||||
|
|
||||||
사용 예시:
|
사용 예시:
|
||||||
from app.lyric.api.routers.v1.lyric import router
|
from app.lyric.api.routers.v1.lyric import router
|
||||||
app.include_router(router)
|
app.include_router(router, prefix="/api/v1")
|
||||||
|
|
||||||
다른 서비스에서 재사용:
|
다른 서비스에서 재사용:
|
||||||
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
|
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
|
||||||
from app.lyric.api.routers.v1.lyric import (
|
from app.lyric.api.routers.v1.lyric import (
|
||||||
get_lyric_status_by_task_id,
|
get_lyric_status_by_task_id,
|
||||||
get_lyric_by_task_id,
|
get_lyric_by_task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 페이지네이션은 pagination 모듈 사용
|
# 페이지네이션은 pagination 모듈 사용
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
from typing import Optional
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select
|
||||||
from app.database.session import get_session
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.home.models import Project, MarketingIntel
|
|
||||||
from app.user.dependencies.auth import get_current_user
|
from app.database.session import get_session
|
||||||
from app.user.models import User
|
from app.home.models import Project
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.lyric.schemas.lyric import (
|
from app.lyric.schemas.lyric import (
|
||||||
GenerateLyricRequest,
|
GenerateLyricRequest,
|
||||||
GenerateLyricResponse,
|
GenerateLyricResponse,
|
||||||
LyricDetailResponse,
|
LyricDetailResponse,
|
||||||
LyricListItem,
|
LyricListItem,
|
||||||
LyricStatusResponse,
|
LyricStatusResponse,
|
||||||
)
|
)
|
||||||
from app.lyric.worker.lyric_task import generate_lyric_background
|
from app.utils.chatgpt_prompt import ChatgptService
|
||||||
from app.utils.chatgpt_prompt import ChatgptService
|
from app.utils.common import generate_task_id
|
||||||
from app.utils.logger import get_logger
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
|
||||||
|
router = APIRouter(prefix="/lyric", tags=["lyric"])
|
||||||
from app.utils.prompts.prompts import lyric_prompt
|
|
||||||
import traceback as tb
|
|
||||||
import json
|
# =============================================================================
|
||||||
# 로거 설정
|
# Reusable Service Functions (다른 모듈에서 import하여 사용 가능)
|
||||||
logger = get_logger("lyric")
|
# =============================================================================
|
||||||
|
|
||||||
router = APIRouter(prefix="/lyric", tags=["Lyric"])
|
|
||||||
|
async def get_lyric_status_by_task_id(
|
||||||
|
session: AsyncSession, task_id: str
|
||||||
# =============================================================================
|
) -> LyricStatusResponse:
|
||||||
# Reusable Service Functions (다른 모듈에서 import하여 사용 가능)
|
"""task_id로 가사 생성 작업의 상태를 조회합니다.
|
||||||
# =============================================================================
|
|
||||||
|
Args:
|
||||||
|
session: SQLAlchemy AsyncSession
|
||||||
async def get_lyric_status_by_task_id(
|
task_id: 작업 고유 식별자
|
||||||
session: AsyncSession, task_id: str
|
|
||||||
) -> LyricStatusResponse:
|
Returns:
|
||||||
"""task_id로 가사 생성 작업의 상태를 조회합니다.
|
LyricStatusResponse: 상태 정보
|
||||||
|
|
||||||
Args:
|
Raises:
|
||||||
session: SQLAlchemy AsyncSession
|
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
||||||
task_id: 작업 고유 식별자
|
|
||||||
|
Usage:
|
||||||
Returns:
|
# 다른 서비스에서 사용
|
||||||
LyricStatusResponse: 상태 정보
|
from app.lyric.api.routers.v1.lyric import get_lyric_status_by_task_id
|
||||||
|
|
||||||
Raises:
|
status_info = await get_lyric_status_by_task_id(session, "some-task-id")
|
||||||
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
if status_info.status == "completed":
|
||||||
|
# 완료 처리
|
||||||
Usage:
|
"""
|
||||||
# 다른 서비스에서 사용
|
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
|
||||||
from app.lyric.api.routers.v1.lyric import get_lyric_status_by_task_id
|
result = await session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
||||||
|
lyric = result.scalar_one_or_none()
|
||||||
status_info = await get_lyric_status_by_task_id(session, "some-task-id")
|
|
||||||
if status_info.status == "completed":
|
if not lyric:
|
||||||
# 완료 처리
|
print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
|
||||||
"""
|
raise HTTPException(
|
||||||
logger.info(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
result = await session.execute(
|
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||||
select(Lyric)
|
)
|
||||||
.where(Lyric.task_id == task_id)
|
|
||||||
.order_by(Lyric.created_at.desc())
|
status_messages = {
|
||||||
.limit(1)
|
"processing": "가사 생성 중입니다.",
|
||||||
)
|
"completed": "가사 생성이 완료되었습니다.",
|
||||||
lyric = result.scalar_one_or_none()
|
"failed": "가사 생성에 실패했습니다.",
|
||||||
|
}
|
||||||
if not lyric:
|
|
||||||
logger.warning(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
|
print(
|
||||||
raise HTTPException(
|
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
)
|
||||||
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
return LyricStatusResponse(
|
||||||
)
|
task_id=lyric.task_id,
|
||||||
|
status=lyric.status,
|
||||||
status_messages = {
|
message=status_messages.get(lyric.status, "알 수 없는 상태입니다."),
|
||||||
"processing": "가사 생성 중입니다.",
|
)
|
||||||
"completed": "가사 생성이 완료되었습니다.",
|
|
||||||
"failed": "가사 생성에 실패했습니다.",
|
|
||||||
}
|
async def get_lyric_by_task_id(
|
||||||
|
session: AsyncSession, task_id: str
|
||||||
logger.info(
|
) -> LyricDetailResponse:
|
||||||
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
|
"""task_id로 생성된 가사 상세 정보를 조회합니다.
|
||||||
)
|
|
||||||
return LyricStatusResponse(
|
Args:
|
||||||
task_id=lyric.task_id,
|
session: SQLAlchemy AsyncSession
|
||||||
status=lyric.status,
|
task_id: 작업 고유 식별자
|
||||||
message=status_messages.get(lyric.status, "알 수 없는 상태입니다."),
|
|
||||||
)
|
Returns:
|
||||||
|
LyricDetailResponse: 가사 상세 정보
|
||||||
|
|
||||||
async def get_lyric_by_task_id(
|
Raises:
|
||||||
session: AsyncSession, task_id: str
|
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
||||||
) -> LyricDetailResponse:
|
|
||||||
"""task_id로 생성된 가사 상세 정보를 조회합니다.
|
Usage:
|
||||||
|
# 다른 서비스에서 사용
|
||||||
Args:
|
from app.lyric.api.routers.v1.lyric import get_lyric_by_task_id
|
||||||
session: SQLAlchemy AsyncSession
|
|
||||||
task_id: 작업 고유 식별자
|
lyric = await get_lyric_by_task_id(session, task_id)
|
||||||
|
"""
|
||||||
Returns:
|
print(f"[get_lyric_by_task_id] START - task_id: {task_id}")
|
||||||
LyricDetailResponse: 가사 상세 정보
|
result = await session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
||||||
|
lyric = result.scalar_one_or_none()
|
||||||
Raises:
|
|
||||||
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
|
if not lyric:
|
||||||
|
print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
|
||||||
Usage:
|
raise HTTPException(
|
||||||
# 다른 서비스에서 사용
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
from app.lyric.api.routers.v1.lyric import get_lyric_by_task_id
|
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
lyric = await get_lyric_by_task_id(session, 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] START - task_id: {task_id}")
|
return LyricDetailResponse(
|
||||||
result = await session.execute(
|
id=lyric.id,
|
||||||
select(Lyric)
|
task_id=lyric.task_id,
|
||||||
.where(Lyric.task_id == task_id)
|
project_id=lyric.project_id,
|
||||||
.order_by(Lyric.created_at.desc())
|
status=lyric.status,
|
||||||
.limit(1)
|
lyric_prompt=lyric.lyric_prompt,
|
||||||
)
|
lyric_result=lyric.lyric_result,
|
||||||
lyric = result.scalar_one_or_none()
|
created_at=lyric.created_at,
|
||||||
|
)
|
||||||
if not lyric:
|
|
||||||
logger.warning(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
|
|
||||||
raise HTTPException(
|
# =============================================================================
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
# API Endpoints
|
||||||
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
# =============================================================================
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
|
@router.post(
|
||||||
return LyricDetailResponse(
|
"/generate",
|
||||||
id=lyric.id,
|
summary="가사 생성",
|
||||||
task_id=lyric.task_id,
|
description="""
|
||||||
project_id=lyric.project_id,
|
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
||||||
status=lyric.status,
|
|
||||||
lyric_result=lyric.lyric_result,
|
## 요청 필드
|
||||||
created_at=lyric.created_at,
|
- **customer_name**: 고객명/가게명 (필수)
|
||||||
)
|
- **region**: 지역명 (필수)
|
||||||
|
- **detail_region_info**: 상세 지역 정보 (선택)
|
||||||
|
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
|
||||||
# =============================================================================
|
|
||||||
# API Endpoints
|
## 반환 정보
|
||||||
# =============================================================================
|
- **success**: 생성 성공 여부
|
||||||
|
- **task_id**: 작업 고유 식별자
|
||||||
|
- **lyric**: 생성된 가사 (성공 시)
|
||||||
@router.post(
|
- **language**: 가사 언어
|
||||||
"/generate",
|
- **error_message**: 에러 메시지 (실패 시)
|
||||||
summary="가사 생성",
|
|
||||||
description="""
|
## 실패 조건
|
||||||
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
- ChatGPT API 오류
|
||||||
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
|
- ChatGPT 거부 응답 (I'm sorry, I cannot 등)
|
||||||
|
- 응답에 ERROR: 포함
|
||||||
## 인증
|
|
||||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
## 사용 예시
|
||||||
|
```
|
||||||
## 요청 필드
|
POST /lyric/generate
|
||||||
- **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수)
|
{
|
||||||
- **customer_name**: 고객명/가게명 (필수)
|
"customer_name": "스테이 머뭄",
|
||||||
- **region**: 지역명 (필수)
|
"region": "군산",
|
||||||
- **detail_region_info**: 상세 지역 정보 (선택)
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
|
"language": "Korean"
|
||||||
|
}
|
||||||
## 반환 정보
|
```
|
||||||
- **success**: 요청 접수 성공 여부
|
|
||||||
- **task_id**: 작업 고유 식별자
|
## 응답 예시 (성공)
|
||||||
- **lyric**: null (백그라운드 처리 중)
|
```json
|
||||||
- **language**: 가사 언어
|
{
|
||||||
- **error_message**: 에러 메시지 (요청 접수 실패 시)
|
"success": true,
|
||||||
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
## 상태 확인
|
"lyric": "인스타 감성의 스테이 머뭄...",
|
||||||
- GET /lyric/status/{task_id} 로 처리 상태 확인
|
"language": "Korean",
|
||||||
- GET /lyric/{task_id} 로 생성된 가사 조회
|
"error_message": null
|
||||||
|
}
|
||||||
## 사용 예시 (cURL)
|
```
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:8000/lyric/generate" \\
|
## 응답 예시 (실패)
|
||||||
-H "Authorization: Bearer {access_token}" \\
|
```json
|
||||||
-H "Content-Type: application/json" \\
|
{
|
||||||
-d '{
|
"success": false,
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"customer_name": "스테이 머뭄",
|
"lyric": null,
|
||||||
"region": "군산",
|
"language": "Korean",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"error_message": "I'm sorry, I can't comply with that request."
|
||||||
"language": "Korean"
|
}
|
||||||
}'
|
```
|
||||||
```
|
""",
|
||||||
|
response_model=GenerateLyricResponse,
|
||||||
## 응답 예시
|
responses={
|
||||||
```json
|
200: {"description": "가사 생성 성공 또는 실패 (success 필드로 구분)"},
|
||||||
{
|
500: {"description": "서버 내부 오류"},
|
||||||
"success": true,
|
},
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
)
|
||||||
"lyric": null,
|
async def generate_lyric(
|
||||||
"language": "Korean",
|
request_body: GenerateLyricRequest,
|
||||||
"error_message": null
|
session: AsyncSession = Depends(get_session),
|
||||||
}
|
) -> GenerateLyricResponse:
|
||||||
```
|
"""고객 정보를 기반으로 가사를 생성합니다."""
|
||||||
""",
|
task_id = await generate_task_id(session=session, table_name=Project)
|
||||||
response_model=GenerateLyricResponse,
|
print(
|
||||||
responses={
|
f"[generate_lyric] START - task_id: {task_id}, customer_name: {request_body.customer_name}, region: {request_body.region}"
|
||||||
200: {"description": "가사 생성 요청 접수 성공"},
|
)
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
500: {"description": "서버 내부 오류"},
|
try:
|
||||||
},
|
# 1. ChatGPT 서비스 초기화 및 프롬프트 생성
|
||||||
)
|
service = ChatgptService(
|
||||||
async def generate_lyric(
|
customer_name=request_body.customer_name,
|
||||||
request_body: GenerateLyricRequest,
|
region=request_body.region,
|
||||||
background_tasks: BackgroundTasks,
|
detail_region_info=request_body.detail_region_info or "",
|
||||||
current_user: User = Depends(get_current_user),
|
language=request_body.language,
|
||||||
session: AsyncSession = Depends(get_session),
|
)
|
||||||
) -> GenerateLyricResponse:
|
prompt = service.build_lyrics_prompt()
|
||||||
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
|
||||||
import time
|
# 2. Project 테이블에 데이터 저장
|
||||||
|
project = Project(
|
||||||
request_start = time.perf_counter()
|
store_name=request_body.customer_name,
|
||||||
task_id = request_body.task_id
|
region=request_body.region,
|
||||||
|
task_id=task_id,
|
||||||
|
detail_region_info=request_body.detail_region_info,
|
||||||
logger.info(f"[generate_lyric] ========== START ==========")
|
language=request_body.language,
|
||||||
logger.info(
|
)
|
||||||
f"[generate_lyric] task_id: {task_id}, "
|
session.add(project)
|
||||||
f"customer_name: {request_body.customer_name}, "
|
await session.commit()
|
||||||
f"region: {request_body.region}"
|
await session.refresh(project) # commit 후 project.id 동기화
|
||||||
)
|
print(
|
||||||
|
f"[generate_lyric] Project saved - project_id: {project.id}, task_id: {task_id}"
|
||||||
try:
|
)
|
||||||
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
|
|
||||||
step1_start = time.perf_counter()
|
# 3. Lyric 테이블에 데이터 저장 (status: processing)
|
||||||
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
lyric = Lyric(
|
||||||
|
project_id=project.id,
|
||||||
# service = ChatgptService(
|
task_id=task_id,
|
||||||
# customer_name=request_body.customer_name,
|
status="processing",
|
||||||
# region=request_body.region,
|
lyric_prompt=prompt,
|
||||||
# detail_region_info=request_body.detail_region_info or "",
|
lyric_result=None,
|
||||||
# language=request_body.language,
|
language=request_body.language,
|
||||||
# )
|
)
|
||||||
|
session.add(lyric)
|
||||||
# prompt = service.build_lyrics_prompt()
|
await (
|
||||||
# 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨.
|
session.commit()
|
||||||
# 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성
|
) # processing 상태를 확실히 저장 (다른 트랜잭션에서 조회 가능)
|
||||||
|
await session.refresh(lyric) # commit 후 객체 상태 동기화
|
||||||
promotional_expressions = {
|
print(
|
||||||
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
|
f"[generate_lyric] Lyric saved (processing) - lyric_id: {lyric.id}, task_id: {task_id}"
|
||||||
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
|
)
|
||||||
"Chinese" : "网红打卡, 治愈系, 旅行, 度假, 拍照圣地",
|
|
||||||
"Japanese" : "インスタ映え, 写真のような一日, 癒し, 旅行, 絶景",
|
# 4. ChatGPT를 통해 가사 생성
|
||||||
"Thai" : "ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย",
|
print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}")
|
||||||
"Vietnamese" : "check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp"
|
result = await service.generate(prompt=prompt)
|
||||||
}# HARD CODED, 어디에 정리하지? 아직 정리되지 않음
|
print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}")
|
||||||
|
|
||||||
timing_rules = {
|
# 5. 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답)
|
||||||
"60s" : """
|
failure_patterns = [
|
||||||
8–12 lines
|
"ERROR:",
|
||||||
Full verse flow, immersive mood
|
"I'm sorry",
|
||||||
"""
|
"I cannot",
|
||||||
}
|
"I can't",
|
||||||
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id))
|
"I apologize",
|
||||||
marketing_intel = marketing_intel_result.scalar_one_or_none()
|
"I'm unable",
|
||||||
|
"I am unable",
|
||||||
|
"I'm not able",
|
||||||
lyric_input_data = {
|
"I am not able",
|
||||||
"customer_name" : request_body.customer_name,
|
]
|
||||||
"region" : request_body.region,
|
is_failure = any(
|
||||||
"detail_region_info" : request_body.detail_region_info or "",
|
pattern.lower() in result.lower() for pattern in failure_patterns
|
||||||
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
)
|
||||||
"language" : request_body.language,
|
|
||||||
"promotional_expression_example" : promotional_expressions[request_body.language],
|
if is_failure:
|
||||||
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
print(f"[generate_lyric] FAILED - task_id: {task_id}, error: {result}")
|
||||||
}
|
lyric.status = "failed"
|
||||||
|
lyric.lyric_result = result
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
await session.commit()
|
||||||
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
|
||||||
|
return GenerateLyricResponse(
|
||||||
# ========== Step 2: Project 조회 또는 생성 ==========
|
success=False,
|
||||||
step2_start = time.perf_counter()
|
task_id=task_id,
|
||||||
logger.debug(f"[generate_lyric] Step 2: Project 조회 또는 생성...")
|
lyric=None,
|
||||||
|
language=request_body.language,
|
||||||
# 기존 Project가 있는지 확인 (재생성 시 재사용)
|
error_message=result,
|
||||||
existing_project_result = await session.execute(
|
)
|
||||||
select(Project).where(Project.task_id == task_id).limit(1)
|
|
||||||
)
|
# 6. 성공 시 Lyric 테이블 업데이트 (status: completed)
|
||||||
project = existing_project_result.scalar_one_or_none()
|
lyric.status = "completed"
|
||||||
|
lyric.lyric_result = result
|
||||||
if project:
|
await session.commit()
|
||||||
# 기존 Project 재사용 (재생성 케이스)
|
|
||||||
logger.info(f"[generate_lyric] 기존 Project 재사용 - project_id: {project.id}, task_id: {task_id}")
|
print(f"[generate_lyric] SUCCESS - task_id: {task_id}")
|
||||||
else:
|
return GenerateLyricResponse(
|
||||||
# 새 Project 생성 (최초 생성 케이스)
|
success=True,
|
||||||
project = Project(
|
task_id=task_id,
|
||||||
store_name=request_body.customer_name,
|
lyric=result,
|
||||||
region=request_body.region,
|
language=request_body.language,
|
||||||
task_id=task_id,
|
error_message=None,
|
||||||
detail_region_info=request_body.detail_region_info,
|
)
|
||||||
language=request_body.language,
|
except Exception as e:
|
||||||
user_uuid=current_user.user_uuid,
|
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
marketing_intelligence = request_body.m_id
|
await session.rollback()
|
||||||
)
|
return GenerateLyricResponse(
|
||||||
session.add(project)
|
success=False,
|
||||||
await session.commit()
|
task_id=task_id,
|
||||||
await session.refresh(project)
|
lyric=None,
|
||||||
logger.info(f"[generate_lyric] 새 Project 생성 - project_id: {project.id}, task_id: {task_id}")
|
language=request_body.language,
|
||||||
|
error_message=str(e),
|
||||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
)
|
||||||
logger.debug(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
|
|
||||||
|
|
||||||
# ========== Step 3: Lyric 테이블에 데이터 저장 ==========
|
@router.get(
|
||||||
step3_start = time.perf_counter()
|
"/status/{task_id}",
|
||||||
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
summary="가사 생성 상태 조회",
|
||||||
|
description="""
|
||||||
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
|
||||||
lyric = Lyric(
|
|
||||||
project_id=project.id,
|
## 상태 값
|
||||||
task_id=task_id,
|
- **processing**: 가사 생성 중
|
||||||
status="processing",
|
- **completed**: 가사 생성 완료
|
||||||
lyric_prompt=estimated_prompt,
|
- **failed**: 가사 생성 실패
|
||||||
lyric_result=None,
|
|
||||||
language=request_body.language,
|
## 사용 예시
|
||||||
)
|
```
|
||||||
session.add(lyric)
|
GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
|
||||||
await session.commit()
|
```
|
||||||
await session.refresh(lyric)
|
""",
|
||||||
|
response_model=LyricStatusResponse,
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
responses={
|
||||||
logger.debug(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)")
|
200: {"description": "상태 조회 성공"},
|
||||||
|
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||||
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
},
|
||||||
step4_start = time.perf_counter()
|
)
|
||||||
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
async def get_lyric_status(
|
||||||
|
task_id: str,
|
||||||
background_tasks.add_task(
|
session: AsyncSession = Depends(get_session),
|
||||||
generate_lyric_background,
|
) -> LyricStatusResponse:
|
||||||
task_id=task_id,
|
"""task_id로 가사 생성 작업 상태를 조회합니다."""
|
||||||
prompt=lyric_prompt,
|
return await get_lyric_status_by_task_id(session, task_id)
|
||||||
lyric_input_data=lyric_input_data,
|
|
||||||
lyric_id=lyric.id,
|
|
||||||
)
|
@router.get(
|
||||||
|
"s",
|
||||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
summary="가사 목록 조회 (페이지네이션)",
|
||||||
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
|
description="""
|
||||||
|
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
||||||
# ========== 완료 ==========
|
|
||||||
total_elapsed = (time.perf_counter() - request_start) * 1000
|
## 파라미터
|
||||||
logger.info(f"[generate_lyric] ========== COMPLETE ==========")
|
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
||||||
logger.info(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
|
- **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100)
|
||||||
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")
|
- **items**: 가사 목록 (completed 상태만)
|
||||||
logger.debug(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms")
|
- **total**: 전체 데이터 수
|
||||||
logger.debug(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)")
|
- **page**: 현재 페이지
|
||||||
|
- **page_size**: 페이지당 데이터 수
|
||||||
# 5. 즉시 응답 반환
|
- **total_pages**: 전체 페이지 수
|
||||||
return GenerateLyricResponse(
|
- **has_next**: 다음 페이지 존재 여부
|
||||||
success=True,
|
- **has_prev**: 이전 페이지 존재 여부
|
||||||
task_id=task_id,
|
|
||||||
lyric=None,
|
## 사용 예시
|
||||||
language=request_body.language,
|
```
|
||||||
error_message=None,
|
GET /lyrics # 기본 조회 (1페이지, 20개)
|
||||||
)
|
GET /lyrics?page=2 # 2페이지 조회
|
||||||
|
GET /lyrics?page=1&page_size=50 # 50개씩 조회
|
||||||
except Exception as e:
|
```
|
||||||
elapsed = (time.perf_counter() - request_start) * 1000
|
|
||||||
logger.error(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
|
## 참고
|
||||||
await session.rollback()
|
- 생성 완료(completed)된 가사만 조회됩니다.
|
||||||
return GenerateLyricResponse(
|
- processing, failed 상태의 가사는 조회되지 않습니다.
|
||||||
success=False,
|
""",
|
||||||
task_id=task_id,
|
response_model=PaginatedResponse[LyricListItem],
|
||||||
lyric=None,
|
responses={
|
||||||
language=request_body.language,
|
200: {"description": "가사 목록 조회 성공"},
|
||||||
error_message=''.join(tb.format_exception(None, e, e.__traceback__)),
|
},
|
||||||
)
|
)
|
||||||
|
async def list_lyrics(
|
||||||
|
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
|
||||||
@router.get(
|
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
|
||||||
"/status/{task_id}",
|
session: AsyncSession = Depends(get_session),
|
||||||
summary="가사 생성 상태 조회",
|
) -> PaginatedResponse[LyricListItem]:
|
||||||
description="""
|
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
|
||||||
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
|
return await get_paginated(
|
||||||
|
session=session,
|
||||||
## 인증
|
model=Lyric,
|
||||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
item_schema=LyricListItem,
|
||||||
|
page=page,
|
||||||
## 상태 값
|
page_size=page_size,
|
||||||
- **processing**: 가사 생성 중
|
filters={"status": "completed"},
|
||||||
- **completed**: 가사 생성 완료
|
order_by="created_at",
|
||||||
- **failed**: 가사 생성 실패
|
order_desc=True,
|
||||||
|
)
|
||||||
## 사용 예시 (cURL)
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:8000/lyric/status/019123ab-cdef-7890-abcd-ef1234567890" \\
|
@router.get(
|
||||||
-H "Authorization: Bearer {access_token}"
|
"/{task_id}",
|
||||||
```
|
summary="가사 상세 조회",
|
||||||
""",
|
description="""
|
||||||
response_model=LyricStatusResponse,
|
task_id로 생성된 가사의 상세 정보를 조회합니다.
|
||||||
responses={
|
|
||||||
200: {"description": "상태 조회 성공"},
|
## 반환 정보
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
- **id**: 가사 ID
|
||||||
404: {"description": "해당 task_id를 찾을 수 없음"},
|
- **task_id**: 작업 고유 식별자
|
||||||
},
|
- **project_id**: 프로젝트 ID
|
||||||
)
|
- **status**: 처리 상태
|
||||||
async def get_lyric_status(
|
- **lyric_prompt**: 가사 생성에 사용된 프롬프트
|
||||||
task_id: str,
|
- **lyric_result**: 생성된 가사 (완료 시)
|
||||||
current_user: User = Depends(get_current_user),
|
- **created_at**: 생성 일시
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> LyricStatusResponse:
|
## 사용 예시
|
||||||
"""task_id로 가사 생성 작업 상태를 조회합니다."""
|
```
|
||||||
return await get_lyric_status_by_task_id(session, task_id)
|
GET /lyric/019123ab-cdef-7890-abcd-ef1234567890
|
||||||
|
```
|
||||||
|
""",
|
||||||
@router.get(
|
response_model=LyricDetailResponse,
|
||||||
"/list",
|
responses={
|
||||||
summary="가사 목록 조회 (페이지네이션)",
|
200: {"description": "가사 조회 성공"},
|
||||||
description="""
|
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||||
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
},
|
||||||
|
)
|
||||||
## 인증
|
async def get_lyric_detail(
|
||||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
task_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
## 파라미터
|
) -> LyricDetailResponse:
|
||||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
"""task_id로 생성된 가사를 조회합니다."""
|
||||||
- **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100)
|
return await get_lyric_by_task_id(session, task_id)
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **items**: 가사 목록 (completed 상태만)
|
|
||||||
- **total**: 전체 데이터 수
|
|
||||||
- **page**: 현재 페이지
|
|
||||||
- **page_size**: 페이지당 데이터 수
|
|
||||||
- **total_pages**: 전체 페이지 수
|
|
||||||
- **has_next**: 다음 페이지 존재 여부
|
|
||||||
- **has_prev**: 이전 페이지 존재 여부
|
|
||||||
|
|
||||||
## 사용 예시 (cURL)
|
|
||||||
```bash
|
|
||||||
# 기본 조회 (1페이지, 20개)
|
|
||||||
curl -X GET "http://localhost:8000/lyric/list" \\
|
|
||||||
-H "Authorization: Bearer {access_token}"
|
|
||||||
|
|
||||||
# 2페이지 조회
|
|
||||||
curl -X GET "http://localhost:8000/lyric/list?page=2" \\
|
|
||||||
-H "Authorization: Bearer {access_token}"
|
|
||||||
|
|
||||||
# 50개씩 조회
|
|
||||||
curl -X GET "http://localhost:8000/lyric/list?page=1&page_size=50" \\
|
|
||||||
-H "Authorization: Bearer {access_token}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 생성 완료(completed)된 가사만 조회됩니다.
|
|
||||||
- processing, failed 상태의 가사는 조회되지 않습니다.
|
|
||||||
""",
|
|
||||||
response_model=PaginatedResponse[LyricListItem],
|
|
||||||
responses={
|
|
||||||
200: {"description": "가사 목록 조회 성공"},
|
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def list_lyrics(
|
|
||||||
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
|
|
||||||
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> PaginatedResponse[LyricListItem]:
|
|
||||||
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
|
|
||||||
return await get_paginated(
|
|
||||||
session=session,
|
|
||||||
model=Lyric,
|
|
||||||
item_schema=LyricListItem,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
filters={"status": "completed"},
|
|
||||||
order_by="created_at",
|
|
||||||
order_desc=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{task_id}",
|
|
||||||
summary="가사 상세 조회",
|
|
||||||
description="""
|
|
||||||
task_id로 생성된 가사의 상세 정보를 조회합니다.
|
|
||||||
|
|
||||||
## 인증
|
|
||||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **id**: 가사 ID
|
|
||||||
- **task_id**: 작업 고유 식별자
|
|
||||||
- **project_id**: 프로젝트 ID
|
|
||||||
- **status**: 처리 상태
|
|
||||||
- **lyric_prompt**: 가사 생성에 사용된 프롬프트
|
|
||||||
- **lyric_result**: 생성된 가사 (완료 시)
|
|
||||||
- **created_at**: 생성 일시
|
|
||||||
|
|
||||||
## 사용 예시 (cURL)
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:8000/lyric/019123ab-cdef-7890-abcd-ef1234567890" \\
|
|
||||||
-H "Authorization: Bearer {access_token}"
|
|
||||||
```
|
|
||||||
""",
|
|
||||||
response_model=LyricDetailResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "가사 조회 성공"},
|
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
404: {"description": "해당 task_id를 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_lyric_detail(
|
|
||||||
task_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> LyricDetailResponse:
|
|
||||||
"""task_id로 생성된 가사를 조회합니다."""
|
|
||||||
return await get_lyric_by_task_id(session, task_id)
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
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)]
|
||||||
|
|
@ -1,142 +1,133 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
|
|
||||||
|
|
||||||
class Lyric(Base):
|
class Lyric(Base):
|
||||||
"""
|
"""
|
||||||
가사 테이블
|
가사 테이블
|
||||||
|
|
||||||
AI를 통해 생성된 가사 정보를 저장합니다.
|
AI를 통해 생성된 가사 정보를 저장합니다.
|
||||||
프롬프트와 생성 결과, 처리 상태를 관리합니다.
|
프롬프트와 생성 결과, 처리 상태를 관리합니다.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
project_id: 연결된 Project의 id (외래키)
|
project_id: 연결된 Project의 id (외래키)
|
||||||
task_id: 가사 생성 작업의 고유 식별자 (UUID7 형식)
|
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
|
||||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||||
lyric_prompt: 가사 생성에 사용된 프롬프트
|
lyric_prompt: 가사 생성에 사용된 프롬프트
|
||||||
lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원)
|
lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원)
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
project: 연결된 Project
|
project: 연결된 Project
|
||||||
songs: 이 가사를 사용한 노래 목록
|
songs: 이 가사를 사용한 노래 목록
|
||||||
videos: 이 가사를 사용한 영상 목록
|
videos: 이 가사를 사용한 영상 목록
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "lyric"
|
__tablename__ = "lyric"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_lyric_task_id", "task_id"),
|
{
|
||||||
Index("idx_lyric_project_id", "project_id"),
|
"mysql_engine": "InnoDB",
|
||||||
Index("idx_lyric_is_deleted", "is_deleted"),
|
"mysql_charset": "utf8mb4",
|
||||||
{
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
"mysql_engine": "InnoDB",
|
},
|
||||||
"mysql_charset": "utf8mb4",
|
)
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
id: Mapped[int] = mapped_column(
|
||||||
)
|
Integer,
|
||||||
|
primary_key=True,
|
||||||
id: Mapped[int] = mapped_column(
|
nullable=False,
|
||||||
Integer,
|
autoincrement=True,
|
||||||
primary_key=True,
|
comment="고유 식별자",
|
||||||
nullable=False,
|
)
|
||||||
autoincrement=True,
|
|
||||||
comment="고유 식별자",
|
project_id: Mapped[int] = mapped_column(
|
||||||
)
|
Integer,
|
||||||
|
ForeignKey("project.id", ondelete="CASCADE"),
|
||||||
project_id: Mapped[int] = mapped_column(
|
nullable=False,
|
||||||
Integer,
|
index=True,
|
||||||
ForeignKey("project.id", ondelete="CASCADE"),
|
comment="연결된 Project의 id",
|
||||||
nullable=False,
|
)
|
||||||
comment="연결된 Project의 id",
|
|
||||||
)
|
task_id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
task_id: Mapped[str] = mapped_column(
|
nullable=False,
|
||||||
String(36),
|
comment="가사 생성 작업 고유 식별자 (UUID)",
|
||||||
nullable=False,
|
)
|
||||||
comment="가사 생성 작업 고유 식별자 (UUID7)",
|
|
||||||
)
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
status: Mapped[str] = mapped_column(
|
nullable=False,
|
||||||
String(50),
|
comment="처리 상태 (processing, completed, failed)",
|
||||||
nullable=False,
|
)
|
||||||
comment="처리 상태 (processing, completed, failed)",
|
|
||||||
)
|
lyric_prompt: Mapped[str] = mapped_column(
|
||||||
|
Text,
|
||||||
lyric_prompt: Mapped[str] = mapped_column(
|
nullable=False,
|
||||||
Text,
|
comment="가사 생성에 사용된 프롬프트",
|
||||||
nullable=False,
|
)
|
||||||
comment="가사 생성에 사용된 프롬프트",
|
|
||||||
)
|
lyric_result: Mapped[str] = mapped_column(
|
||||||
|
LONGTEXT,
|
||||||
lyric_result: Mapped[str] = mapped_column(
|
nullable=True,
|
||||||
LONGTEXT,
|
comment="생성된 가사 결과",
|
||||||
nullable=True,
|
)
|
||||||
comment="생성된 가사 결과",
|
|
||||||
)
|
language: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
language: Mapped[str] = mapped_column(
|
nullable=False,
|
||||||
String(50),
|
default="Korean",
|
||||||
nullable=False,
|
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
default="Korean",
|
)
|
||||||
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
|
||||||
)
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
nullable=True,
|
||||||
Boolean,
|
server_default=func.now(),
|
||||||
nullable=False,
|
comment="생성 일시",
|
||||||
default=False,
|
)
|
||||||
comment="소프트 삭제 여부 (True: 삭제됨)",
|
|
||||||
)
|
# Relationships
|
||||||
|
project: Mapped["Project"] = relationship(
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
"Project",
|
||||||
DateTime,
|
back_populates="lyrics",
|
||||||
nullable=True,
|
)
|
||||||
server_default=func.now(),
|
|
||||||
comment="생성 일시",
|
songs: Mapped[List["Song"]] = relationship(
|
||||||
)
|
"Song",
|
||||||
|
back_populates="lyric",
|
||||||
# Relationships
|
cascade="all, delete-orphan",
|
||||||
project: Mapped["Project"] = relationship(
|
lazy="selectin",
|
||||||
"Project",
|
)
|
||||||
back_populates="lyrics",
|
|
||||||
)
|
videos: Mapped[List["Video"]] = relationship(
|
||||||
|
"Video",
|
||||||
songs: Mapped[List["Song"]] = relationship(
|
back_populates="lyric",
|
||||||
"Song",
|
cascade="all, delete-orphan",
|
||||||
back_populates="lyric",
|
lazy="selectin",
|
||||||
cascade="all, delete-orphan",
|
)
|
||||||
lazy="selectin",
|
|
||||||
)
|
def __repr__(self) -> str:
|
||||||
|
def truncate(value: str | None, max_len: int = 10) -> str:
|
||||||
videos: Mapped[List["Video"]] = relationship(
|
if value is None:
|
||||||
"Video",
|
return "None"
|
||||||
back_populates="lyric",
|
return (value[:max_len] + "...") if len(value) > max_len else value
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="selectin",
|
return (
|
||||||
)
|
f"<Lyric("
|
||||||
|
f"id={self.id}, "
|
||||||
def __repr__(self) -> str:
|
f"task_id='{truncate(self.task_id)}', "
|
||||||
def truncate(value: str | None, max_len: int = 10) -> str:
|
f"status='{self.status}'"
|
||||||
if value is None:
|
f")>"
|
||||||
return "None"
|
)
|
||||||
return (value[:max_len] + "...") if len(value) > max_len else value
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"<Lyric("
|
|
||||||
f"id={self.id}, "
|
|
||||||
f"task_id='{truncate(self.task_id)}', "
|
|
||||||
f"status='{self.status}'"
|
|
||||||
f")>"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,221 +1,165 @@
|
||||||
"""
|
"""
|
||||||
Lyric API Schemas
|
Lyric API Schemas
|
||||||
|
|
||||||
이 모듈은 가사 관련 API 엔드포인트에서 사용되는 Pydantic 스키마를 정의합니다.
|
이 모듈은 가사 관련 API 엔드포인트에서 사용되는 Pydantic 스키마를 정의합니다.
|
||||||
|
|
||||||
사용 예시:
|
사용 예시:
|
||||||
from app.lyric.schemas.lyric import (
|
from app.lyric.schemas.lyric import (
|
||||||
LyricStatusResponse,
|
LyricStatusResponse,
|
||||||
LyricDetailResponse,
|
LyricDetailResponse,
|
||||||
LyricListItem,
|
LyricListItem,
|
||||||
)
|
)
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.pagination import PaginatedResponse
|
||||||
|
|
||||||
# 라우터에서 response_model로 사용
|
# 라우터에서 response_model로 사용
|
||||||
@router.get("/lyric/{task_id}", response_model=LyricDetailResponse)
|
@router.get("/lyric/{task_id}", response_model=LyricDetailResponse)
|
||||||
async def get_lyric(task_id: str):
|
async def get_lyric(task_id: str):
|
||||||
...
|
...
|
||||||
|
|
||||||
# 페이지네이션 응답 (공통 스키마 사용)
|
# 페이지네이션 응답 (공통 스키마 사용)
|
||||||
@router.get("/songs", response_model=PaginatedResponse[SongListItem])
|
@router.get("/songs", response_model=PaginatedResponse[SongListItem])
|
||||||
async def list_songs(...):
|
async def list_songs(...):
|
||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class GenerateLyricRequest(BaseModel):
|
class GenerateLyricRequest(BaseModel):
|
||||||
"""가사 생성 요청 스키마
|
"""가사 생성 요청 스키마
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
POST /lyric/generate
|
POST /lyric/generate
|
||||||
Request body for generating lyrics.
|
Request body for generating lyrics.
|
||||||
|
|
||||||
Example Request:
|
Example Request:
|
||||||
{
|
{
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"customer_name": "스테이 머뭄",
|
||||||
"customer_name": "스테이 머뭄",
|
"region": "군산",
|
||||||
"region": "군산",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"language": "Korean"
|
||||||
"language": "Korean",
|
}
|
||||||
"m_id" : 1
|
"""
|
||||||
}
|
|
||||||
"""
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
model_config = ConfigDict(
|
"example": {
|
||||||
json_schema_extra={
|
"customer_name": "스테이 머뭄",
|
||||||
"example": {
|
"region": "군산",
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"customer_name": "스테이 머뭄",
|
"language": "Korean",
|
||||||
"region": "군산",
|
}
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
}
|
||||||
"language": "Korean",
|
}
|
||||||
"m_id" : 1
|
|
||||||
}
|
customer_name: str = Field(..., description="고객명/가게명")
|
||||||
}
|
region: str = Field(..., description="지역명")
|
||||||
)
|
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||||
|
language: str = Field(
|
||||||
task_id: str = Field(
|
default="Korean",
|
||||||
..., description="작업 고유 식별자 (이미지 업로드 시 생성된 task_id)"
|
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
customer_name: str = Field(..., description="고객명/가게명")
|
|
||||||
region: str = Field(..., description="지역명")
|
|
||||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
class GenerateLyricResponse(BaseModel):
|
||||||
language: str = Field(
|
"""가사 생성 응답 스키마
|
||||||
default="Korean",
|
|
||||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
Usage:
|
||||||
)
|
POST /lyric/generate
|
||||||
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
Returns the generated lyrics.
|
||||||
|
|
||||||
|
Note:
|
||||||
class GenerateLyricResponse(BaseModel):
|
실패 조건:
|
||||||
"""가사 생성 응답 스키마
|
- ChatGPT API 오류
|
||||||
|
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등)
|
||||||
Usage:
|
- 응답에 ERROR: 포함
|
||||||
POST /lyric/generate
|
|
||||||
Returns the generated lyrics.
|
Example Response (Success):
|
||||||
|
{
|
||||||
Note:
|
"success": true,
|
||||||
실패 조건:
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
- ChatGPT API 오류
|
"lyric": "인스타 감성의 스테이 머뭄...",
|
||||||
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등)
|
"language": "Korean",
|
||||||
- 응답에 ERROR: 포함
|
"error_message": null
|
||||||
"""
|
}
|
||||||
|
|
||||||
model_config = ConfigDict(
|
Example Response (Failure):
|
||||||
json_schema_extra={
|
{
|
||||||
"example": {
|
"success": false,
|
||||||
"success": True,
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"lyric": null,
|
||||||
"lyric": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
"language": "Korean",
|
||||||
"language": "Korean",
|
"error_message": "I'm sorry, I can't comply with that request."
|
||||||
"error_message": None,
|
}
|
||||||
}
|
"""
|
||||||
}
|
|
||||||
)
|
success: bool = Field(..., description="생성 성공 여부")
|
||||||
|
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
|
||||||
success: bool = Field(..., description="생성 성공 여부")
|
lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)")
|
||||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
|
language: str = Field(..., description="가사 언어")
|
||||||
lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시, ChatGPT 거부 응답 포함)")
|
||||||
language: str = Field(..., description="가사 언어")
|
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시, ChatGPT 거부 응답 포함)")
|
|
||||||
|
class LyricStatusResponse(BaseModel):
|
||||||
|
"""가사 상태 조회 응답 스키마
|
||||||
class LyricStatusResponse(BaseModel):
|
|
||||||
"""가사 상태 조회 응답 스키마
|
Usage:
|
||||||
|
GET /lyric/status/{task_id}
|
||||||
Usage:
|
Returns the current processing status of a lyric generation task.
|
||||||
GET /lyric/status/{task_id}
|
|
||||||
Returns the current processing status of a lyric generation task.
|
Example Response:
|
||||||
|
{
|
||||||
Status Values:
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
- processing: 가사 생성 진행 중
|
"status": "completed",
|
||||||
- completed: 가사 생성 완료
|
"message": "가사 생성이 완료되었습니다."
|
||||||
- failed: ChatGPT API 오류 또는 생성 실패
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
json_schema_extra={
|
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||||
"examples": [
|
message: str = Field(..., description="상태 메시지")
|
||||||
{
|
|
||||||
"summary": "성공",
|
|
||||||
"value": {
|
class LyricDetailResponse(BaseModel):
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"""가사 상세 조회 응답 스키마
|
||||||
"status": "completed",
|
|
||||||
"message": "가사 생성이 완료되었습니다.",
|
Usage:
|
||||||
}
|
GET /lyric/{task_id}
|
||||||
},
|
Returns the generated lyric content for a specific task.
|
||||||
{
|
|
||||||
"summary": "실패",
|
Example Response:
|
||||||
"value": {
|
{
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"id": 1,
|
||||||
"status": "failed",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"message": "가사 생성에 실패했습니다.",
|
"project_id": 1,
|
||||||
}
|
"status": "completed",
|
||||||
}
|
"lyric_prompt": "...",
|
||||||
]
|
"lyric_result": "생성된 가사...",
|
||||||
}
|
"created_at": "2024-01-01T12:00:00"
|
||||||
)
|
}
|
||||||
|
"""
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
id: int = Field(..., description="가사 ID")
|
||||||
message: str = Field(..., description="상태 메시지")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
|
project_id: int = Field(..., description="프로젝트 ID")
|
||||||
|
status: str = Field(..., description="처리 상태")
|
||||||
class LyricDetailResponse(BaseModel):
|
lyric_prompt: str = Field(..., description="가사 생성 프롬프트")
|
||||||
"""가사 상세 조회 응답 스키마
|
lyric_result: Optional[str] = Field(None, description="생성된 가사")
|
||||||
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
Usage:
|
|
||||||
GET /lyric/{task_id}
|
|
||||||
Returns the generated lyric content for a specific task.
|
class LyricListItem(BaseModel):
|
||||||
|
"""가사 목록 아이템 스키마
|
||||||
Note:
|
|
||||||
- status가 "failed"인 경우 lyric_result에 에러 메시지가 저장됩니다.
|
Usage:
|
||||||
- 에러 메시지 형식: "ChatGPT Error: {message}" 또는 "Error: {message}"
|
Used as individual items in paginated lyric list responses.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
id: int = Field(..., description="가사 ID")
|
||||||
json_schema_extra={
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
"examples": [
|
status: str = Field(..., description="처리 상태")
|
||||||
{
|
lyric_result: Optional[str] = Field(None, description="생성된 가사 (미리보기)")
|
||||||
"summary": "성공",
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
"value": {
|
|
||||||
"id": 1,
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"project_id": 1,
|
|
||||||
"status": "completed",
|
|
||||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
|
||||||
"created_at": "2024-01-15T12:00:00",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "실패",
|
|
||||||
"value": {
|
|
||||||
"id": 1,
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"project_id": 1,
|
|
||||||
"status": "failed",
|
|
||||||
"lyric_result": "ChatGPT Error: Response incomplete: max_output_tokens",
|
|
||||||
"created_at": "2024-01-15T12:00:00",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
id: int = Field(..., description="가사 ID")
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
project_id: int = Field(..., description="프로젝트 ID")
|
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
|
||||||
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
|
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
|
||||||
|
|
||||||
|
|
||||||
class LyricListItem(BaseModel):
|
|
||||||
"""가사 목록 아이템 스키마
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
Used as individual items in paginated lyric list responses.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"id": 1,
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "completed",
|
|
||||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서...",
|
|
||||||
"created_at": "2024-01-15T12:00:00",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
id: int = Field(..., description="가사 ID")
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
status: str = Field(..., description="처리 상태")
|
|
||||||
lyric_result: Optional[str] = Field(None, description="생성된 가사 (미리보기)")
|
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,91 @@
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class StoreData:
|
class StoreData:
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
store_name: str
|
store_name: str
|
||||||
store_category: str | None = None
|
store_category: str | None = None
|
||||||
store_region: str | None = None
|
store_region: str | None = None
|
||||||
store_address: str | None = None
|
store_address: str | None = None
|
||||||
store_phone_number: str | None = None
|
store_phone_number: str | None = None
|
||||||
store_info: str | None = None
|
store_info: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AttributeData:
|
class AttributeData:
|
||||||
id: int
|
id: int
|
||||||
attr_category: str
|
attr_category: str
|
||||||
attr_value: str
|
attr_value: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SongSampleData:
|
class SongSampleData:
|
||||||
id: int
|
id: int
|
||||||
ai: str
|
ai: str
|
||||||
ai_model: str
|
ai_model: str
|
||||||
sample_song: str
|
sample_song: str
|
||||||
season: str | None = None
|
season: str | None = None
|
||||||
num_of_people: int | None = None
|
num_of_people: int | None = None
|
||||||
people_category: str | None = None
|
people_category: str | None = None
|
||||||
genre: str | None = None
|
genre: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PromptTemplateData:
|
class PromptTemplateData:
|
||||||
id: int
|
id: int
|
||||||
prompt: str
|
prompt: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SongFormData:
|
class SongFormData:
|
||||||
store_name: str
|
store_name: str
|
||||||
store_id: str
|
store_id: str
|
||||||
prompts: str
|
prompts: str
|
||||||
attributes: Dict[str, str] = field(default_factory=dict)
|
attributes: Dict[str, str] = field(default_factory=dict)
|
||||||
attributes_str: str = ""
|
attributes_str: str = ""
|
||||||
lyrics_ids: List[int] = field(default_factory=list)
|
lyrics_ids: List[int] = field(default_factory=list)
|
||||||
llm_model: str = "gpt-5-mini"
|
llm_model: str = "gpt-4o"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_form(cls, request: Request):
|
async def from_form(cls, request: Request):
|
||||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
|
|
||||||
# 고정 필드명들
|
# 고정 필드명들
|
||||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
||||||
|
|
||||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
||||||
lyrics_ids = []
|
lyrics_ids = []
|
||||||
attributes = {}
|
attributes = {}
|
||||||
|
|
||||||
for key, value in form_data.items():
|
for key, value in form_data.items():
|
||||||
if key.startswith("lyrics-"):
|
if key.startswith("lyrics-"):
|
||||||
lyrics_id = key.split("-")[1]
|
lyrics_id = key.split("-")[1]
|
||||||
lyrics_ids.append(int(lyrics_id))
|
lyrics_ids.append(int(lyrics_id))
|
||||||
elif key not in fixed_keys:
|
elif key not in fixed_keys:
|
||||||
attributes[key] = value
|
attributes[key] = value
|
||||||
|
|
||||||
# attributes를 문자열로 변환
|
# attributes를 문자열로 변환
|
||||||
attributes_str = (
|
attributes_str = (
|
||||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
||||||
if attributes
|
if attributes
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
store_name=form_data.get("store_info_name", ""),
|
store_name=form_data.get("store_info_name", ""),
|
||||||
store_id=form_data.get("store_id", ""),
|
store_id=form_data.get("store_id", ""),
|
||||||
attributes=attributes,
|
attributes=attributes,
|
||||||
attributes_str=attributes_str,
|
attributes_str=attributes_str,
|
||||||
lyrics_ids=lyrics_ids,
|
lyrics_ids=lyrics_ids,
|
||||||
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
llm_model=form_data.get("llm_model", "gpt-4o"),
|
||||||
prompts=form_data.get("prompts", ""),
|
prompts=form_data.get("prompts", ""),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
class BaseService:
|
class BaseService:
|
||||||
def __init__(self, model, session: AsyncSession):
|
def __init__(self, model, session: AsyncSession):
|
||||||
self.model = model
|
self.model = model
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
async def _get(self, id: UUID):
|
async def _get(self, id: UUID):
|
||||||
return await self.session.get(self.model, id)
|
return await self.session.get(self.model, id)
|
||||||
|
|
||||||
async def _add(self, entity):
|
async def _add(self, entity):
|
||||||
self.session.add(entity)
|
self.session.add(entity)
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
await self.session.refresh(entity)
|
await self.session.refresh(entity)
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
async def _update(self, entity):
|
async def _update(self, entity):
|
||||||
return await self._add(entity)
|
return await self._add(entity)
|
||||||
|
|
||||||
async def _delete(self, entity):
|
async def _delete(self, entity):
|
||||||
await self.session.delete(entity)
|
await self.session.delete(entity)
|
||||||
|
|
@ -0,0 +1,852 @@
|
||||||
|
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-4o",
|
||||||
|
"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="서비스 처리 중 오류가 발생했습니다.",
|
||||||
|
)
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
"""
|
|
||||||
Lyric Background Tasks
|
|
||||||
|
|
||||||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
|
||||||
from app.lyric.models import Lyric
|
|
||||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
|
||||||
from app.utils.prompts.prompts import Prompt
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
|
|
||||||
# 로거 설정
|
|
||||||
logger = get_logger("lyric")
|
|
||||||
|
|
||||||
|
|
||||||
async def _update_lyric_status(
|
|
||||||
task_id: str,
|
|
||||||
status: str,
|
|
||||||
result: str | None = None,
|
|
||||||
lyric_id: int | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Lyric 테이블의 상태를 업데이트합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 프로젝트 task_id
|
|
||||||
status: 변경할 상태 ("processing", "completed", "failed")
|
|
||||||
result: 가사 결과 또는 에러 메시지
|
|
||||||
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 업데이트 성공 여부
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
if lyric_id:
|
|
||||||
# lyric_id로 특정 레코드 조회 (재생성 시에도 정확한 레코드 업데이트)
|
|
||||||
query_result = await session.execute(
|
|
||||||
select(Lyric).where(Lyric.id == lyric_id)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 기존 방식: task_id로 최신 레코드 조회
|
|
||||||
query_result = await session.execute(
|
|
||||||
select(Lyric)
|
|
||||||
.where(Lyric.task_id == task_id)
|
|
||||||
.order_by(Lyric.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
lyric = query_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if lyric:
|
|
||||||
lyric.status = status
|
|
||||||
if result is not None:
|
|
||||||
lyric.lyric_result = result
|
|
||||||
await session.commit()
|
|
||||||
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}, lyric_id: {lyric_id}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, lyric_id: {lyric_id}, error: {e}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, lyric_id: {lyric_id}, error: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_lyric_background(
|
|
||||||
task_id: str,
|
|
||||||
prompt: Prompt,
|
|
||||||
lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input
|
|
||||||
lyric_id: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 프로젝트 task_id
|
|
||||||
prompt: ChatGPT에 전달할 프롬프트
|
|
||||||
lyric_input_data: 프롬프트 입력 데이터
|
|
||||||
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
task_start = time.perf_counter()
|
|
||||||
logger.info(f"[generate_lyric_background] START - task_id: {task_id}")
|
|
||||||
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()
|
|
||||||
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
|
||||||
|
|
||||||
# service = ChatgptService(
|
|
||||||
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
|
||||||
# region="",
|
|
||||||
# detail_region_info="",
|
|
||||||
# language=language,
|
|
||||||
# )
|
|
||||||
|
|
||||||
chatgpt = ChatgptService()
|
|
||||||
|
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
|
||||||
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}")
|
|
||||||
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)")
|
|
||||||
|
|
||||||
# ========== Step 3: DB 상태 업데이트 ==========
|
|
||||||
step3_start = time.perf_counter()
|
|
||||||
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
|
||||||
|
|
||||||
await _update_lyric_status(task_id, "completed", result, lyric_id)
|
|
||||||
|
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
|
||||||
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")
|
|
||||||
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 ChatGPTResponseError as e:
|
|
||||||
elapsed = (time.perf_counter() - task_start) * 1000
|
|
||||||
logger.error(
|
|
||||||
f"[generate_lyric_background] ChatGPT ERROR - task_id: {task_id}, "
|
|
||||||
f"status: {e.status}, code: {e.error_code}, message: {e.error_message} ({elapsed:.1f}ms)"
|
|
||||||
)
|
|
||||||
await _update_lyric_status(task_id, "failed", f"ChatGPT Error: {e.error_message}", lyric_id)
|
|
||||||
|
|
||||||
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)", exc_info=True)
|
|
||||||
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}", lyric_id)
|
|
||||||
|
|
||||||
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)", exc_info=True)
|
|
||||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
"""
|
|
||||||
SNS API 라우터
|
|
||||||
|
|
||||||
Instagram 업로드 관련 엔드포인트를 제공합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.sns.schemas.sns_schema import InstagramUploadRequest, InstagramUploadResponse
|
|
||||||
from app.user.dependencies.auth import get_current_user
|
|
||||||
from app.user.models import Platform, SocialAccount, User
|
|
||||||
from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# SNS 예외 클래스 정의
|
|
||||||
# =============================================================================
|
|
||||||
class SNSException(HTTPException):
|
|
||||||
"""SNS 관련 기본 예외"""
|
|
||||||
|
|
||||||
def __init__(self, status_code: int, code: str, message: str):
|
|
||||||
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountNotFoundError(SNSException):
|
|
||||||
"""소셜 계정 없음"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "연동된 소셜 계정을 찾을 수 없습니다."):
|
|
||||||
super().__init__(status.HTTP_404_NOT_FOUND, "SOCIAL_ACCOUNT_NOT_FOUND", message)
|
|
||||||
|
|
||||||
|
|
||||||
class VideoNotFoundError(SNSException):
|
|
||||||
"""비디오 없음"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "해당 작업 ID에 대한 비디오를 찾을 수 없습니다."):
|
|
||||||
super().__init__(status.HTTP_404_NOT_FOUND, "VIDEO_NOT_FOUND", message)
|
|
||||||
|
|
||||||
|
|
||||||
class VideoUrlNotReadyError(SNSException):
|
|
||||||
"""비디오 URL 미준비"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "비디오가 아직 준비되지 않았습니다."):
|
|
||||||
super().__init__(status.HTTP_400_BAD_REQUEST, "VIDEO_URL_NOT_READY", message)
|
|
||||||
|
|
||||||
|
|
||||||
class InstagramUploadError(SNSException):
|
|
||||||
"""Instagram 업로드 실패"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "Instagram 업로드에 실패했습니다."):
|
|
||||||
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_UPLOAD_ERROR", message)
|
|
||||||
|
|
||||||
|
|
||||||
class InstagramRateLimitError(SNSException):
|
|
||||||
"""Instagram API Rate Limit"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "Instagram API 호출 제한을 초과했습니다.", retry_after: int = 60):
|
|
||||||
super().__init__(
|
|
||||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
"INSTAGRAM_RATE_LIMIT",
|
|
||||||
f"{message} {retry_after}초 후 다시 시도해주세요.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class InstagramAuthError(SNSException):
|
|
||||||
"""Instagram 인증 오류"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "Instagram 인증에 실패했습니다. 계정을 다시 연동해주세요."):
|
|
||||||
super().__init__(status.HTTP_401_UNAUTHORIZED, "INSTAGRAM_AUTH_ERROR", message)
|
|
||||||
|
|
||||||
|
|
||||||
class InstagramContainerTimeoutError(SNSException):
|
|
||||||
"""Instagram 미디어 처리 타임아웃"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "Instagram 미디어 처리 시간이 초과되었습니다."):
|
|
||||||
super().__init__(status.HTTP_504_GATEWAY_TIMEOUT, "INSTAGRAM_CONTAINER_TIMEOUT", message)
|
|
||||||
|
|
||||||
|
|
||||||
class InstagramContainerError(SNSException):
|
|
||||||
"""Instagram 미디어 컨테이너 오류"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "Instagram 미디어 처리에 실패했습니다."):
|
|
||||||
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_CONTAINER_ERROR", message)
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/sns", tags=["SNS"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/instagram/upload/{task_id}",
|
|
||||||
summary="Instagram 비디오 업로드",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
task_id에 해당하는 비디오를 Instagram에 업로드합니다.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **task_id**: 비디오 생성 작업 고유 식별자
|
|
||||||
|
|
||||||
## 요청 본문
|
|
||||||
- **caption**: 게시물 캡션 (선택, 최대 2200자)
|
|
||||||
- **share_to_feed**: 피드에 공유 여부 (기본값: true)
|
|
||||||
|
|
||||||
## 인증
|
|
||||||
- Bearer 토큰 필요 (Authorization: Bearer <token>)
|
|
||||||
- 사용자의 Instagram 계정이 연동되어 있어야 합니다.
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **task_id**: 작업 고유 식별자
|
|
||||||
- **state**: 업로드 상태 (completed, failed)
|
|
||||||
- **message**: 상태 메시지
|
|
||||||
- **media_id**: Instagram 미디어 ID (성공 시)
|
|
||||||
- **permalink**: Instagram 게시물 URL (성공 시)
|
|
||||||
- **error**: 에러 메시지 (실패 시)
|
|
||||||
""",
|
|
||||||
response_model=InstagramUploadResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "업로드 성공"},
|
|
||||||
400: {"description": "비디오 URL 미준비"},
|
|
||||||
401: {"description": "인증 실패"},
|
|
||||||
404: {"description": "비디오 또는 소셜 계정 없음"},
|
|
||||||
429: {"description": "Instagram API Rate Limit"},
|
|
||||||
500: {"description": "업로드 실패"},
|
|
||||||
504: {"description": "타임아웃"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def upload_to_instagram(
|
|
||||||
task_id: str,
|
|
||||||
request: InstagramUploadRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> InstagramUploadResponse:
|
|
||||||
"""Instagram에 비디오를 업로드합니다."""
|
|
||||||
logger.info(f"[upload_to_instagram] START - task_id: {task_id}, user_uuid: {current_user.user_uuid}")
|
|
||||||
|
|
||||||
# Step 1: 사용자의 Instagram 소셜 계정 조회
|
|
||||||
social_account_result = await session.execute(
|
|
||||||
select(SocialAccount).where(
|
|
||||||
SocialAccount.user_uuid == current_user.user_uuid,
|
|
||||||
SocialAccount.platform == Platform.INSTAGRAM,
|
|
||||||
SocialAccount.is_active == True, # noqa: E712
|
|
||||||
SocialAccount.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
social_account = social_account_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if social_account is None:
|
|
||||||
logger.warning(f"[upload_to_instagram] Instagram 계정 없음 - user_uuid: {current_user.user_uuid}")
|
|
||||||
raise SocialAccountNotFoundError("연동된 Instagram 계정을 찾을 수 없습니다.")
|
|
||||||
|
|
||||||
logger.info(f"[upload_to_instagram] 소셜 계정 확인 - social_account_id: {social_account.id}")
|
|
||||||
|
|
||||||
# Step 2: task_id로 비디오 조회 (가장 최근 것)
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video)
|
|
||||||
.where(
|
|
||||||
Video.task_id == task_id,
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.order_by(Video.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
video = video_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if video is None:
|
|
||||||
logger.warning(f"[upload_to_instagram] 비디오 없음 - task_id: {task_id}")
|
|
||||||
raise VideoNotFoundError(f"task_id '{task_id}'에 해당하는 비디오를 찾을 수 없습니다.")
|
|
||||||
|
|
||||||
if video.result_movie_url is None:
|
|
||||||
logger.warning(f"[upload_to_instagram] 비디오 URL 미준비 - task_id: {task_id}, status: {video.status}")
|
|
||||||
raise VideoUrlNotReadyError("비디오가 아직 처리 중입니다. 잠시 후 다시 시도해주세요.")
|
|
||||||
|
|
||||||
logger.info(f"[upload_to_instagram] 비디오 확인 - video_id: {video.id}, url: {video.result_movie_url[:50]}...")
|
|
||||||
|
|
||||||
# Step 3: Instagram 업로드
|
|
||||||
try:
|
|
||||||
async with InstagramClient(access_token=social_account.access_token) as client:
|
|
||||||
# 접속 테스트 (계정 ID 조회)
|
|
||||||
await client.get_account_id()
|
|
||||||
logger.info("[upload_to_instagram] Instagram 접속 확인 완료")
|
|
||||||
|
|
||||||
# 비디오 업로드
|
|
||||||
media = await client.publish_video(
|
|
||||||
video_url=video.result_movie_url,
|
|
||||||
caption=request.caption,
|
|
||||||
share_to_feed=request.share_to_feed,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[upload_to_instagram] SUCCESS - task_id: {task_id}, "
|
|
||||||
f"media_id: {media.id}, permalink: {media.permalink}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return InstagramUploadResponse(
|
|
||||||
task_id=task_id,
|
|
||||||
state="completed",
|
|
||||||
message="Instagram 업로드 완료",
|
|
||||||
media_id=media.id,
|
|
||||||
permalink=media.permalink,
|
|
||||||
error=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_state, message, extra_info = parse_instagram_error(e)
|
|
||||||
logger.error(f"[upload_to_instagram] FAILED - task_id: {task_id}, error_state: {error_state}, message: {message}")
|
|
||||||
|
|
||||||
match error_state:
|
|
||||||
case ErrorState.RATE_LIMIT:
|
|
||||||
retry_after = extra_info.get("retry_after", 60)
|
|
||||||
raise InstagramRateLimitError(retry_after=retry_after)
|
|
||||||
|
|
||||||
case ErrorState.AUTH_ERROR:
|
|
||||||
raise InstagramAuthError()
|
|
||||||
|
|
||||||
case ErrorState.CONTAINER_TIMEOUT:
|
|
||||||
raise InstagramContainerTimeoutError()
|
|
||||||
|
|
||||||
case ErrorState.CONTAINER_ERROR:
|
|
||||||
status = extra_info.get("status", "UNKNOWN")
|
|
||||||
raise InstagramContainerError(f"미디어 처리 실패: {status}")
|
|
||||||
|
|
||||||
case _:
|
|
||||||
raise InstagramUploadError(f"Instagram 업로드 실패: {message}")
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
from sqladmin import ModelView
|
|
||||||
|
|
||||||
from app.sns.models import SNSUploadTask
|
|
||||||
|
|
||||||
|
|
||||||
class SNSUploadTaskAdmin(ModelView, model=SNSUploadTask):
|
|
||||||
name = "SNS 업로드 작업"
|
|
||||||
name_plural = "SNS 업로드 작업 목록"
|
|
||||||
icon = "fa-solid fa-share-from-square"
|
|
||||||
category = "SNS 관리"
|
|
||||||
page_size = 20
|
|
||||||
|
|
||||||
column_list = [
|
|
||||||
"id",
|
|
||||||
"user_uuid",
|
|
||||||
"task_id",
|
|
||||||
"social_account_id",
|
|
||||||
"is_scheduled",
|
|
||||||
"status",
|
|
||||||
"scheduled_at",
|
|
||||||
"uploaded_at",
|
|
||||||
"created_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
column_details_list = [
|
|
||||||
"id",
|
|
||||||
"user_uuid",
|
|
||||||
"task_id",
|
|
||||||
"social_account_id",
|
|
||||||
"is_scheduled",
|
|
||||||
"scheduled_at",
|
|
||||||
"url",
|
|
||||||
"caption",
|
|
||||||
"status",
|
|
||||||
"uploaded_at",
|
|
||||||
"created_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
form_excluded_columns = ["created_at", "user", "social_account"]
|
|
||||||
|
|
||||||
column_searchable_list = [
|
|
||||||
SNSUploadTask.user_uuid,
|
|
||||||
SNSUploadTask.task_id,
|
|
||||||
SNSUploadTask.status,
|
|
||||||
]
|
|
||||||
|
|
||||||
column_default_sort = (SNSUploadTask.created_at, True)
|
|
||||||
|
|
||||||
column_sortable_list = [
|
|
||||||
SNSUploadTask.id,
|
|
||||||
SNSUploadTask.user_uuid,
|
|
||||||
SNSUploadTask.social_account_id,
|
|
||||||
SNSUploadTask.is_scheduled,
|
|
||||||
SNSUploadTask.status,
|
|
||||||
SNSUploadTask.scheduled_at,
|
|
||||||
SNSUploadTask.uploaded_at,
|
|
||||||
SNSUploadTask.created_at,
|
|
||||||
]
|
|
||||||
|
|
||||||
column_labels = {
|
|
||||||
"id": "ID",
|
|
||||||
"user_uuid": "사용자 UUID",
|
|
||||||
"task_id": "작업 ID",
|
|
||||||
"social_account_id": "소셜 계정 ID",
|
|
||||||
"is_scheduled": "예약 여부",
|
|
||||||
"scheduled_at": "예약 일시",
|
|
||||||
"url": "미디어 URL",
|
|
||||||
"caption": "캡션",
|
|
||||||
"status": "상태",
|
|
||||||
"uploaded_at": "업로드 일시",
|
|
||||||
"created_at": "생성일시",
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
"""
|
|
||||||
SNS 모듈 SQLAlchemy 모델 정의
|
|
||||||
|
|
||||||
SNS 업로드 작업 관리 모델입니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.database.session import Base
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.user.models import SocialAccount, User
|
|
||||||
|
|
||||||
|
|
||||||
class SNSUploadTask(Base):
|
|
||||||
"""
|
|
||||||
SNS 업로드 작업 테이블
|
|
||||||
|
|
||||||
SNS 플랫폼에 콘텐츠를 업로드하는 작업을 관리합니다.
|
|
||||||
즉시 업로드 또는 예약 업로드를 지원합니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: 고유 식별자 (자동 증가)
|
|
||||||
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
|
||||||
task_id: 외부 작업 식별자 (비디오 생성 작업 등)
|
|
||||||
is_scheduled: 예약 작업 여부 (True: 예약, False: 즉시)
|
|
||||||
scheduled_at: 예약 발행 일시 (분 단위까지)
|
|
||||||
social_account_id: 소셜 계정 외래키 (SocialAccount.id 참조)
|
|
||||||
url: 업로드할 미디어 URL
|
|
||||||
caption: 게시물 캡션/설명
|
|
||||||
status: 발행 상태 (pending: 예약 대기, completed: 완료, error: 에러)
|
|
||||||
uploaded_at: 실제 업로드 완료 일시
|
|
||||||
created_at: 작업 생성 일시
|
|
||||||
|
|
||||||
발행 상태 (status):
|
|
||||||
- pending: 예약 대기 중 (예약 작업이거나 처리 전)
|
|
||||||
- processing: 처리 중
|
|
||||||
- completed: 발행 완료
|
|
||||||
- error: 에러 발생
|
|
||||||
|
|
||||||
Relationships:
|
|
||||||
user: 작업 소유 사용자 (User 테이블 참조)
|
|
||||||
social_account: 발행 대상 소셜 계정 (SocialAccount 테이블 참조)
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "sns_upload_task"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_sns_upload_task_user_uuid", "user_uuid"),
|
|
||||||
Index("idx_sns_upload_task_task_id", "task_id"),
|
|
||||||
Index("idx_sns_upload_task_social_account_id", "social_account_id"),
|
|
||||||
Index("idx_sns_upload_task_status", "status"),
|
|
||||||
Index("idx_sns_upload_task_is_scheduled", "is_scheduled"),
|
|
||||||
Index("idx_sns_upload_task_scheduled_at", "scheduled_at"),
|
|
||||||
Index("idx_sns_upload_task_created_at", "created_at"),
|
|
||||||
{
|
|
||||||
"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_uuid: Mapped[str] = mapped_column(
|
|
||||||
String(36),
|
|
||||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="사용자 UUID (User.user_uuid 참조)",
|
|
||||||
)
|
|
||||||
|
|
||||||
task_id: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(100),
|
|
||||||
nullable=True,
|
|
||||||
comment="외부 작업 식별자 (비디오 생성 작업 ID 등)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 예약 설정
|
|
||||||
# ==========================================================================
|
|
||||||
is_scheduled: Mapped[bool] = mapped_column(
|
|
||||||
Boolean,
|
|
||||||
nullable=False,
|
|
||||||
default=False,
|
|
||||||
comment="예약 작업 여부 (True: 예약 발행, False: 즉시 발행)",
|
|
||||||
)
|
|
||||||
|
|
||||||
scheduled_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=True,
|
|
||||||
comment="예약 발행 일시 (분 단위까지 지정)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 소셜 계정 연결
|
|
||||||
# ==========================================================================
|
|
||||||
social_account_id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("social_account.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="소셜 계정 외래키 (SocialAccount.id 참조)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 업로드 콘텐츠
|
|
||||||
# ==========================================================================
|
|
||||||
url: Mapped[str] = mapped_column(
|
|
||||||
String(2048),
|
|
||||||
nullable=False,
|
|
||||||
comment="업로드할 미디어 URL",
|
|
||||||
)
|
|
||||||
|
|
||||||
caption: Mapped[Optional[str]] = mapped_column(
|
|
||||||
Text,
|
|
||||||
nullable=True,
|
|
||||||
comment="게시물 캡션/설명",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 발행 상태
|
|
||||||
# ==========================================================================
|
|
||||||
status: Mapped[str] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=False,
|
|
||||||
default="pending",
|
|
||||||
comment="발행 상태 (pending: 예약 대기, processing: 처리 중, completed: 완료, error: 에러)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 시간 정보
|
|
||||||
# ==========================================================================
|
|
||||||
uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=True,
|
|
||||||
comment="실제 업로드 완료 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="작업 생성 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# Relationships
|
|
||||||
# ==========================================================================
|
|
||||||
user: Mapped["User"] = relationship(
|
|
||||||
"User",
|
|
||||||
foreign_keys=[user_uuid],
|
|
||||||
primaryjoin="SNSUploadTask.user_uuid == User.user_uuid",
|
|
||||||
)
|
|
||||||
|
|
||||||
social_account: Mapped["SocialAccount"] = relationship(
|
|
||||||
"SocialAccount",
|
|
||||||
foreign_keys=[social_account_id],
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<SNSUploadTask("
|
|
||||||
f"id={self.id}, "
|
|
||||||
f"user_uuid='{self.user_uuid}', "
|
|
||||||
f"social_account_id={self.social_account_id}, "
|
|
||||||
f"status='{self.status}', "
|
|
||||||
f"is_scheduled={self.is_scheduled}"
|
|
||||||
f")>"
|
|
||||||
)
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
"""
|
|
||||||
SNS API Schemas
|
|
||||||
|
|
||||||
Instagram 업로드 관련 Pydantic 스키마를 정의합니다.
|
|
||||||
"""
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class InstagramUploadRequest(BaseModel):
|
|
||||||
"""Instagram 업로드 요청 스키마
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
POST /sns/instagram/upload/{task_id}
|
|
||||||
Instagram에 비디오를 업로드합니다.
|
|
||||||
|
|
||||||
Example Request:
|
|
||||||
{
|
|
||||||
"caption": "Test video from Instagram POC #test",
|
|
||||||
"share_to_feed": true
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"caption": "Test video from Instagram POC #test",
|
|
||||||
"share_to_feed": True,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
caption: str = Field(
|
|
||||||
default="",
|
|
||||||
description="게시물 캡션",
|
|
||||||
max_length=2200,
|
|
||||||
)
|
|
||||||
share_to_feed: bool = Field(
|
|
||||||
default=True,
|
|
||||||
description="피드에 공유 여부",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class InstagramUploadResponse(BaseModel):
|
|
||||||
"""Instagram 업로드 응답 스키마
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
POST /sns/instagram/upload/{task_id}
|
|
||||||
Instagram 업로드 작업의 결과를 반환합니다.
|
|
||||||
|
|
||||||
Example Response (성공):
|
|
||||||
{
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"state": "completed",
|
|
||||||
"message": "Instagram 업로드 완료",
|
|
||||||
"media_id": "17841405822304914",
|
|
||||||
"permalink": "https://www.instagram.com/p/ABC123/",
|
|
||||||
"error": null
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"state": "completed",
|
|
||||||
"message": "Instagram 업로드 완료",
|
|
||||||
"media_id": "17841405822304914",
|
|
||||||
"permalink": "https://www.instagram.com/p/ABC123/",
|
|
||||||
"error": None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
state: str = Field(..., description="업로드 상태 (pending, processing, completed, failed)")
|
|
||||||
message: str = Field(..., description="상태 메시지")
|
|
||||||
media_id: Optional[str] = Field(default=None, description="Instagram 미디어 ID (성공 시)")
|
|
||||||
permalink: Optional[str] = Field(default=None, description="Instagram 게시물 URL (성공 시)")
|
|
||||||
error: Optional[str] = Field(default=None, description="에러 메시지 (실패 시)")
|
|
||||||
|
|
||||||
|
|
||||||
class Media(BaseModel):
|
|
||||||
"""Instagram 미디어 정보"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
media_type: Optional[str] = None
|
|
||||||
media_url: Optional[str] = None
|
|
||||||
thumbnail_url: Optional[str] = None
|
|
||||||
caption: Optional[str] = None
|
|
||||||
timestamp: Optional[datetime] = None
|
|
||||||
permalink: Optional[str] = None
|
|
||||||
like_count: int = 0
|
|
||||||
comments_count: int = 0
|
|
||||||
children: Optional[list["Media"]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class MediaContainer(BaseModel):
|
|
||||||
"""미디어 컨테이너 상태"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
status_code: Optional[str] = None
|
|
||||||
status: Optional[str] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_finished(self) -> bool:
|
|
||||||
return self.status_code == "FINISHED"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_error(self) -> bool:
|
|
||||||
return self.status_code == "ERROR"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_in_progress(self) -> bool:
|
|
||||||
return self.status_code == "IN_PROGRESS"
|
|
||||||
|
|
||||||
|
|
||||||
class APIError(BaseModel):
|
|
||||||
"""API 에러 응답"""
|
|
||||||
|
|
||||||
message: str
|
|
||||||
type: Optional[str] = None
|
|
||||||
code: Optional[int] = None
|
|
||||||
error_subcode: Optional[int] = None
|
|
||||||
fbtrace_id: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
|
||||||
"""에러 응답 래퍼"""
|
|
||||||
|
|
||||||
error: APIError
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
"""
|
|
||||||
Social Media Integration Module
|
|
||||||
|
|
||||||
소셜 미디어 플랫폼 연동 및 영상 업로드 기능을 제공합니다.
|
|
||||||
|
|
||||||
지원 플랫폼:
|
|
||||||
- YouTube (구현됨)
|
|
||||||
- Instagram (추후 구현)
|
|
||||||
- Facebook (추후 구현)
|
|
||||||
- TikTok (추후 구현)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.social.constants import SocialPlatform, UploadStatus
|
|
||||||
|
|
||||||
__all__ = ["SocialPlatform", "UploadStatus"]
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
"""
|
|
||||||
Social API Module
|
|
||||||
"""
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
"""
|
|
||||||
Social API Routers
|
|
||||||
"""
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
"""
|
|
||||||
Social API Routers v1
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.social.api.routers.v1.oauth import router as oauth_router
|
|
||||||
from app.social.api.routers.v1.upload import router as upload_router
|
|
||||||
from app.social.api.routers.v1.seo import router as seo_router
|
|
||||||
__all__ = ["oauth_router", "upload_router", "seo_router"]
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
"""
|
|
||||||
소셜 OAuth API 라우터
|
|
||||||
|
|
||||||
소셜 미디어 계정 연동 관련 엔드포인트를 제공합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from config import social_oauth_settings
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.social.constants import SocialPlatform
|
|
||||||
from app.social.schemas import (
|
|
||||||
MessageResponse,
|
|
||||||
SocialAccountListResponse,
|
|
||||||
SocialAccountResponse,
|
|
||||||
SocialConnectResponse,
|
|
||||||
)
|
|
||||||
from app.social.services import social_account_service
|
|
||||||
from app.user.dependencies import get_current_user
|
|
||||||
from app.user.models import User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/oauth", tags=["Social OAuth"])
|
|
||||||
|
|
||||||
|
|
||||||
def _build_redirect_url(is_success: bool, params: dict) -> str:
|
|
||||||
"""OAuth 리다이렉트 URL 생성"""
|
|
||||||
base_url = social_oauth_settings.OAUTH_FRONTEND_URL.rstrip("/")
|
|
||||||
path = (
|
|
||||||
social_oauth_settings.OAUTH_SUCCESS_PATH
|
|
||||||
if is_success
|
|
||||||
else social_oauth_settings.OAUTH_ERROR_PATH
|
|
||||||
)
|
|
||||||
return f"{base_url}{path}?{urlencode(params)}"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{platform}/connect",
|
|
||||||
response_model=SocialConnectResponse,
|
|
||||||
summary="소셜 계정 연동 시작",
|
|
||||||
description="""
|
|
||||||
소셜 미디어 계정 연동을 시작합니다.
|
|
||||||
|
|
||||||
## 지원 플랫폼
|
|
||||||
- **youtube**: YouTube (Google OAuth)
|
|
||||||
- instagram, facebook, tiktok: 추후 지원 예정
|
|
||||||
|
|
||||||
## 플로우
|
|
||||||
1. 이 엔드포인트를 호출하여 `auth_url`과 `state`를 받음
|
|
||||||
2. 프론트엔드에서 `auth_url`로 사용자를 리다이렉트
|
|
||||||
3. 사용자가 플랫폼에서 권한 승인
|
|
||||||
4. 플랫폼이 `/callback` 엔드포인트로 리다이렉트
|
|
||||||
5. 연동 완료 후 프론트엔드로 리다이렉트
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
async def start_connect(
|
|
||||||
platform: SocialPlatform,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
) -> SocialConnectResponse:
|
|
||||||
"""
|
|
||||||
소셜 계정 연동 시작
|
|
||||||
|
|
||||||
OAuth 인증 URL을 생성하고 state 토큰을 반환합니다.
|
|
||||||
프론트엔드에서 반환된 auth_url로 사용자를 리다이렉트하면 됩니다.
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[OAUTH_API] 소셜 연동 시작 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return await social_account_service.start_connect(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
platform=platform,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{platform}/callback",
|
|
||||||
summary="OAuth 콜백",
|
|
||||||
description="""
|
|
||||||
소셜 플랫폼의 OAuth 콜백을 처리합니다.
|
|
||||||
|
|
||||||
이 엔드포인트는 소셜 플랫폼에서 직접 호출되며,
|
|
||||||
사용자를 프론트엔드로 리다이렉트합니다.
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
async def oauth_callback(
|
|
||||||
platform: SocialPlatform,
|
|
||||||
code: str | None = Query(None, description="OAuth 인가 코드"),
|
|
||||||
state: str | None = Query(None, description="CSRF 방지용 state 토큰"),
|
|
||||||
error: str | None = Query(None, description="OAuth 에러 코드 (사용자 취소 등)"),
|
|
||||||
error_description: str | None = Query(None, description="OAuth 에러 설명"),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> RedirectResponse:
|
|
||||||
"""
|
|
||||||
OAuth 콜백 처리
|
|
||||||
|
|
||||||
소셜 플랫폼에서 리다이렉트된 후 호출됩니다.
|
|
||||||
인가 코드로 토큰을 교환하고 계정을 연동합니다.
|
|
||||||
"""
|
|
||||||
# 사용자가 취소하거나 에러가 발생한 경우
|
|
||||||
if error:
|
|
||||||
logger.info(
|
|
||||||
f"[OAUTH_API] OAuth 취소/에러 - "
|
|
||||||
f"platform: {platform.value}, error: {error}, description: {error_description}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 에러 메시지 생성
|
|
||||||
if error == "access_denied":
|
|
||||||
error_message = "사용자가 연동을 취소했습니다."
|
|
||||||
else:
|
|
||||||
error_message = error_description or error
|
|
||||||
|
|
||||||
redirect_url = _build_redirect_url(
|
|
||||||
is_success=False,
|
|
||||||
params={
|
|
||||||
"platform": platform.value,
|
|
||||||
"error": error_message,
|
|
||||||
"cancelled": "true" if error == "access_denied" else "false",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return RedirectResponse(url=redirect_url, status_code=302)
|
|
||||||
|
|
||||||
# code나 state가 없는 경우
|
|
||||||
if not code or not state:
|
|
||||||
logger.warning(
|
|
||||||
f"[OAUTH_API] OAuth 콜백 파라미터 누락 - "
|
|
||||||
f"platform: {platform.value}, code: {bool(code)}, state: {bool(state)}"
|
|
||||||
)
|
|
||||||
redirect_url = _build_redirect_url(
|
|
||||||
is_success=False,
|
|
||||||
params={
|
|
||||||
"platform": platform.value,
|
|
||||||
"error": "잘못된 요청입니다. 다시 시도해주세요.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return RedirectResponse(url=redirect_url, status_code=302)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[OAUTH_API] OAuth 콜백 수신 - "
|
|
||||||
f"platform: {platform.value}, code: {code[:20]}..."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
account = await social_account_service.handle_callback(
|
|
||||||
code=code,
|
|
||||||
state=state,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 성공 시 프론트엔드로 리다이렉트 (계정 정보 포함)
|
|
||||||
redirect_url = _build_redirect_url(
|
|
||||||
is_success=True,
|
|
||||||
params={
|
|
||||||
"platform": platform.value,
|
|
||||||
"account_id": account.id,
|
|
||||||
"channel_name": account.display_name or account.platform_username or "",
|
|
||||||
"profile_image": account.profile_image_url or "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
logger.info(f"[OAUTH_API] 연동 성공, 리다이렉트 - url: {redirect_url}")
|
|
||||||
return RedirectResponse(url=redirect_url, status_code=302)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[OAUTH_API] OAuth 콜백 처리 실패 - error: {e}")
|
|
||||||
# 실패 시 에러 페이지로 리다이렉트
|
|
||||||
redirect_url = _build_redirect_url(
|
|
||||||
is_success=False,
|
|
||||||
params={
|
|
||||||
"platform": platform.value,
|
|
||||||
"error": str(e),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return RedirectResponse(url=redirect_url, status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/accounts",
|
|
||||||
response_model=SocialAccountListResponse,
|
|
||||||
summary="연동된 소셜 계정 목록 조회",
|
|
||||||
description="현재 사용자가 연동한 모든 소셜 계정 목록을 반환합니다.",
|
|
||||||
)
|
|
||||||
async def get_connected_accounts(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> SocialAccountListResponse:
|
|
||||||
"""
|
|
||||||
연동된 소셜 계정 목록 조회
|
|
||||||
|
|
||||||
현재 로그인한 사용자가 연동한 모든 소셜 계정을 조회합니다.
|
|
||||||
"""
|
|
||||||
logger.info(f"[OAUTH_API] 연동 계정 목록 조회 - user_uuid: {current_user.user_uuid}")
|
|
||||||
|
|
||||||
accounts = await social_account_service.get_connected_accounts(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
return SocialAccountListResponse(
|
|
||||||
accounts=accounts,
|
|
||||||
total=len(accounts),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/accounts/{platform}",
|
|
||||||
response_model=SocialAccountResponse,
|
|
||||||
summary="특정 플랫폼 연동 계정 조회",
|
|
||||||
description="특정 플랫폼에 연동된 계정 정보를 반환합니다.",
|
|
||||||
)
|
|
||||||
async def get_account_by_platform(
|
|
||||||
platform: SocialPlatform,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> SocialAccountResponse:
|
|
||||||
"""
|
|
||||||
특정 플랫폼 연동 계정 조회
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[OAUTH_API] 특정 플랫폼 계정 조회 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
|
|
||||||
)
|
|
||||||
|
|
||||||
account = await social_account_service.get_account_by_platform(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
platform=platform,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
if account is None:
|
|
||||||
from app.social.exceptions import SocialAccountNotFoundError
|
|
||||||
|
|
||||||
raise SocialAccountNotFoundError(platform=platform.value)
|
|
||||||
|
|
||||||
return social_account_service._to_response(account)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/accounts/{account_id}",
|
|
||||||
response_model=MessageResponse,
|
|
||||||
summary="소셜 계정 연동 해제 (account_id)",
|
|
||||||
description="""
|
|
||||||
소셜 미디어 계정 연동을 해제합니다.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **account_id**: 연동 해제할 소셜 계정 ID (SocialAccount.id)
|
|
||||||
|
|
||||||
## 연동 해제 시
|
|
||||||
- 해당 플랫폼으로의 업로드가 불가능해집니다
|
|
||||||
- 기존 업로드 기록은 유지됩니다
|
|
||||||
- 재연동 시 동의 화면이 스킵됩니다
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
async def disconnect_by_account_id(
|
|
||||||
account_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> MessageResponse:
|
|
||||||
"""
|
|
||||||
소셜 계정 연동 해제 (account_id 기준)
|
|
||||||
|
|
||||||
account_id로 특정 소셜 계정의 연동을 해제합니다.
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[OAUTH_API] 소셜 연동 해제 (by account_id) - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, account_id: {account_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
platform = await social_account_service.disconnect_by_account_id(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
account_id=account_id,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
return MessageResponse(
|
|
||||||
success=True,
|
|
||||||
message=f"{platform} 계정 연동이 해제되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{platform}/disconnect",
|
|
||||||
response_model=MessageResponse,
|
|
||||||
summary="소셜 계정 연동 해제 (platform)",
|
|
||||||
description="""
|
|
||||||
소셜 미디어 계정 연동을 해제합니다.
|
|
||||||
|
|
||||||
**주의**: 이 API는 플랫폼당 1개의 계정만 연동된 경우에 사용합니다.
|
|
||||||
여러 채널이 연동된 경우 `DELETE /accounts/{account_id}`를 사용하세요.
|
|
||||||
|
|
||||||
연동 해제 시:
|
|
||||||
- 해당 플랫폼으로의 업로드가 불가능해집니다
|
|
||||||
- 기존 업로드 기록은 유지됩니다
|
|
||||||
""",
|
|
||||||
deprecated=True,
|
|
||||||
)
|
|
||||||
async def disconnect(
|
|
||||||
platform: SocialPlatform,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> MessageResponse:
|
|
||||||
"""
|
|
||||||
소셜 계정 연동 해제 (platform 기준)
|
|
||||||
|
|
||||||
플랫폼으로 연동된 첫 번째 계정을 해제합니다.
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[OAUTH_API] 소셜 연동 해제 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
|
|
||||||
)
|
|
||||||
|
|
||||||
await social_account_service.disconnect(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
platform=platform,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
return MessageResponse(
|
|
||||||
success=True,
|
|
||||||
message=f"{platform.value} 계정 연동이 해제되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
|
|
||||||
import logging, json
|
|
||||||
|
|
||||||
from redis.asyncio import Redis
|
|
||||||
|
|
||||||
from config import social_oauth_settings, db_settings
|
|
||||||
from app.social.constants import YOUTUBE_SEO_HASH
|
|
||||||
from sqlalchemy import select, func
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from app.social.schemas import (
|
|
||||||
YoutubeDescriptionRequest,
|
|
||||||
YoutubeDescriptionResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.user.dependencies import get_current_user
|
|
||||||
from app.user.models import User
|
|
||||||
from app.home.models import Project, MarketingIntel
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from app.utils.prompts.prompts import yt_upload_prompt
|
|
||||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
|
||||||
|
|
||||||
redis_seo_client = Redis(
|
|
||||||
host=db_settings.REDIS_HOST,
|
|
||||||
port=db_settings.REDIS_PORT,
|
|
||||||
db=0,
|
|
||||||
decode_responses=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter(prefix="/seo", tags=["Social SEO"])
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/youtube",
|
|
||||||
response_model=YoutubeDescriptionResponse,
|
|
||||||
summary="유튜브 SEO descrption 생성",
|
|
||||||
description="유튜브 업로드 시 사용할 descrption을 SEO 적용하여 생성",
|
|
||||||
)
|
|
||||||
async def youtube_seo_description(
|
|
||||||
request_body: YoutubeDescriptionRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> YoutubeDescriptionResponse:
|
|
||||||
|
|
||||||
# TODO : 나중에 Session Task_id 검증 미들웨어 만들면 추가해주세요.
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[youtube_seo_description] Try Cache - user: {current_user.user_uuid} / task_id : {request_body.task_id}"
|
|
||||||
)
|
|
||||||
cached = await get_yt_seo_in_redis(request_body.task_id)
|
|
||||||
if cached: # redis hit
|
|
||||||
return cached
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[youtube_seo_description] Cache miss - user: {current_user.user_uuid} "
|
|
||||||
)
|
|
||||||
updated_seo = await make_youtube_seo_description(request_body.task_id, current_user, session)
|
|
||||||
await set_yt_seo_in_redis(request_body.task_id, updated_seo)
|
|
||||||
|
|
||||||
return updated_seo
|
|
||||||
|
|
||||||
async def make_youtube_seo_description(
|
|
||||||
task_id: str,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> YoutubeDescriptionResponse:
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[make_youtube_seo_description] START - user: {current_user.user_uuid} "
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
project_query = await session.execute(
|
|
||||||
select(Project)
|
|
||||||
.where(
|
|
||||||
Project.task_id == task_id,
|
|
||||||
Project.user_uuid == current_user.user_uuid)
|
|
||||||
.order_by(Project.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
|
|
||||||
project = project_query.scalar_one_or_none()
|
|
||||||
marketing_query = await session.execute(
|
|
||||||
select(MarketingIntel)
|
|
||||||
.where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
marketing_intelligence = marketing_query.scalar_one_or_none()
|
|
||||||
|
|
||||||
hashtags = marketing_intelligence.intel_result["target_keywords"]
|
|
||||||
|
|
||||||
yt_seo_input_data = {
|
|
||||||
"customer_name" : project.store_name,
|
|
||||||
"detail_region_info" : project.detail_region_info,
|
|
||||||
"marketing_intelligence_summary" : json.dumps(marketing_intelligence.intel_result, ensure_ascii=False),
|
|
||||||
"language" : project.language,
|
|
||||||
"target_keywords" : hashtags
|
|
||||||
}
|
|
||||||
chatgpt = ChatgptService()
|
|
||||||
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
|
||||||
result_dict = {
|
|
||||||
"title" : yt_seo_output.title,
|
|
||||||
"description" : yt_seo_output.description,
|
|
||||||
"keywords": hashtags
|
|
||||||
}
|
|
||||||
|
|
||||||
result = YoutubeDescriptionResponse(**result_dict)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[youtube_seo_description] EXCEPTION - error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_yt_seo_in_redis(task_id:str) -> YoutubeDescriptionResponse | None:
|
|
||||||
field = f"task_id:{task_id}"
|
|
||||||
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
|
|
||||||
if yt_seo_info:
|
|
||||||
yt_seo = json.loads(yt_seo_info)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
return YoutubeDescriptionResponse(**yt_seo)
|
|
||||||
|
|
||||||
async def set_yt_seo_in_redis(task_id:str, yt_seo : YoutubeDescriptionResponse) -> None:
|
|
||||||
field = f"task_id:{task_id}"
|
|
||||||
yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False)
|
|
||||||
await redis_seo_client.hsetex(YOUTUBE_SEO_HASH, field, yt_seo_info, ex=3600)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
@ -1,424 +0,0 @@
|
||||||
"""
|
|
||||||
소셜 업로드 API 라우터
|
|
||||||
|
|
||||||
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging, json
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy import select, func
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.social.constants import SocialPlatform, UploadStatus
|
|
||||||
from app.social.exceptions import SocialAccountNotFoundError, VideoNotFoundError
|
|
||||||
from app.social.models import SocialUpload
|
|
||||||
from app.social.schemas import (
|
|
||||||
MessageResponse,
|
|
||||||
SocialUploadHistoryItem,
|
|
||||||
SocialUploadHistoryResponse,
|
|
||||||
SocialUploadRequest,
|
|
||||||
SocialUploadResponse,
|
|
||||||
SocialUploadStatusResponse,
|
|
||||||
)
|
|
||||||
from app.social.services import social_account_service
|
|
||||||
from app.social.worker.upload_task import process_social_upload
|
|
||||||
from app.user.dependencies import get_current_user
|
|
||||||
from app.user.models import User
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/upload", tags=["Social Upload"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"",
|
|
||||||
response_model=SocialUploadResponse,
|
|
||||||
summary="소셜 플랫폼에 영상 업로드 요청",
|
|
||||||
description="""
|
|
||||||
영상을 소셜 미디어 플랫폼에 업로드합니다.
|
|
||||||
|
|
||||||
## 사전 조건
|
|
||||||
- 해당 플랫폼에 계정이 연동되어 있어야 합니다
|
|
||||||
- 영상이 completed 상태여야 합니다 (result_movie_url 필요)
|
|
||||||
|
|
||||||
## 요청 필드
|
|
||||||
- **video_id**: 업로드할 영상 ID
|
|
||||||
- **social_account_id**: 업로드할 소셜 계정 ID (연동 계정 목록 조회 API에서 확인)
|
|
||||||
- **title**: 영상 제목 (최대 100자)
|
|
||||||
- **description**: 영상 설명 (최대 5000자)
|
|
||||||
- **tags**: 태그 목록
|
|
||||||
- **privacy_status**: 공개 상태 (public, unlisted, private)
|
|
||||||
- **scheduled_at**: 예약 게시 시간 (선택사항)
|
|
||||||
|
|
||||||
## 업로드 상태
|
|
||||||
업로드는 백그라운드에서 처리되며, 상태를 폴링하여 확인할 수 있습니다:
|
|
||||||
- `pending`: 업로드 대기 중
|
|
||||||
- `uploading`: 업로드 진행 중
|
|
||||||
- `processing`: 플랫폼에서 처리 중
|
|
||||||
- `completed`: 업로드 완료
|
|
||||||
- `failed`: 업로드 실패
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
async def upload_to_social(
|
|
||||||
body: SocialUploadRequest,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> SocialUploadResponse:
|
|
||||||
"""
|
|
||||||
소셜 플랫폼에 영상 업로드 요청
|
|
||||||
|
|
||||||
백그라운드에서 영상을 다운로드하고 소셜 플랫폼에 업로드합니다.
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_API] 업로드 요청 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, "
|
|
||||||
f"video_id: {body.video_id}, "
|
|
||||||
f"social_account_id: {body.social_account_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. 영상 조회 및 검증
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video).where(Video.id == body.video_id)
|
|
||||||
)
|
|
||||||
video = video_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not video:
|
|
||||||
logger.warning(f"[UPLOAD_API] 영상 없음 - video_id: {body.video_id}")
|
|
||||||
raise VideoNotFoundError(video_id=body.video_id)
|
|
||||||
|
|
||||||
if not video.result_movie_url:
|
|
||||||
logger.warning(f"[UPLOAD_API] 영상 URL 없음 - video_id: {body.video_id}")
|
|
||||||
raise VideoNotFoundError(
|
|
||||||
video_id=body.video_id,
|
|
||||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. 소셜 계정 조회 (social_account_id로 직접 조회, 소유권 검증 포함)
|
|
||||||
account = await social_account_service.get_account_by_id(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
account_id=body.social_account_id,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not account:
|
|
||||||
logger.warning(
|
|
||||||
f"[UPLOAD_API] 연동 계정 없음 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, social_account_id: {body.social_account_id}"
|
|
||||||
)
|
|
||||||
raise SocialAccountNotFoundError()
|
|
||||||
|
|
||||||
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
|
|
||||||
in_progress_result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.video_id == body.video_id,
|
|
||||||
SocialUpload.social_account_id == account.id,
|
|
||||||
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
in_progress_upload = in_progress_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if in_progress_upload:
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_API] 진행 중인 업로드 존재 - upload_id: {in_progress_upload.id}"
|
|
||||||
)
|
|
||||||
return SocialUploadResponse(
|
|
||||||
success=True,
|
|
||||||
upload_id=in_progress_upload.id,
|
|
||||||
platform=account.platform,
|
|
||||||
status=in_progress_upload.status,
|
|
||||||
message="이미 업로드가 진행 중입니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 업로드 순번 계산 (동일 video + account 조합에서 최대 순번 + 1)
|
|
||||||
max_seq_result = await session.execute(
|
|
||||||
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
|
||||||
SocialUpload.video_id == body.video_id,
|
|
||||||
SocialUpload.social_account_id == account.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
max_seq = max_seq_result.scalar() or 0
|
|
||||||
next_seq = max_seq + 1
|
|
||||||
|
|
||||||
# 5. 새 업로드 레코드 생성 (항상 새로 생성하여 이력 보존)
|
|
||||||
social_upload = SocialUpload(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
video_id=body.video_id,
|
|
||||||
social_account_id=account.id,
|
|
||||||
upload_seq=next_seq,
|
|
||||||
platform=account.platform,
|
|
||||||
status=UploadStatus.PENDING.value,
|
|
||||||
upload_progress=0,
|
|
||||||
title=body.title,
|
|
||||||
description=body.description,
|
|
||||||
tags=body.tags,
|
|
||||||
privacy_status=body.privacy_status.value,
|
|
||||||
platform_options={
|
|
||||||
**(body.platform_options or {}),
|
|
||||||
"scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None,
|
|
||||||
},
|
|
||||||
retry_count=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
session.add(social_upload)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(social_upload)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_API] 업로드 레코드 생성 - "
|
|
||||||
f"upload_id: {social_upload.id}, video_id: {body.video_id}, "
|
|
||||||
f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 6. 백그라운드 태스크 등록
|
|
||||||
background_tasks.add_task(process_social_upload, social_upload.id)
|
|
||||||
|
|
||||||
return SocialUploadResponse(
|
|
||||||
success=True,
|
|
||||||
upload_id=social_upload.id,
|
|
||||||
platform=account.platform,
|
|
||||||
status=social_upload.status,
|
|
||||||
message="업로드 요청이 접수되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{upload_id}/status",
|
|
||||||
response_model=SocialUploadStatusResponse,
|
|
||||||
summary="업로드 상태 조회",
|
|
||||||
description="특정 업로드 작업의 상태를 조회합니다.",
|
|
||||||
)
|
|
||||||
async def get_upload_status(
|
|
||||||
upload_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> SocialUploadStatusResponse:
|
|
||||||
"""
|
|
||||||
업로드 상태 조회
|
|
||||||
"""
|
|
||||||
logger.info(f"[UPLOAD_API] 상태 조회 - upload_id: {upload_id}")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.id == upload_id,
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not upload:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="업로드 정보를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return SocialUploadStatusResponse(
|
|
||||||
upload_id=upload.id,
|
|
||||||
video_id=upload.video_id,
|
|
||||||
social_account_id=upload.social_account_id,
|
|
||||||
upload_seq=upload.upload_seq,
|
|
||||||
platform=upload.platform,
|
|
||||||
status=UploadStatus(upload.status),
|
|
||||||
upload_progress=upload.upload_progress,
|
|
||||||
title=upload.title,
|
|
||||||
platform_video_id=upload.platform_video_id,
|
|
||||||
platform_url=upload.platform_url,
|
|
||||||
error_message=upload.error_message,
|
|
||||||
retry_count=upload.retry_count,
|
|
||||||
created_at=upload.created_at,
|
|
||||||
uploaded_at=upload.uploaded_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/history",
|
|
||||||
response_model=SocialUploadHistoryResponse,
|
|
||||||
summary="업로드 이력 조회",
|
|
||||||
description="사용자의 소셜 미디어 업로드 이력을 조회합니다.",
|
|
||||||
)
|
|
||||||
async def get_upload_history(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"),
|
|
||||||
status: Optional[UploadStatus] = Query(None, description="상태 필터"),
|
|
||||||
page: int = Query(1, ge=1, description="페이지 번호"),
|
|
||||||
size: int = Query(20, ge=1, le=100, description="페이지 크기"),
|
|
||||||
) -> SocialUploadHistoryResponse:
|
|
||||||
"""
|
|
||||||
업로드 이력 조회
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_API] 이력 조회 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, page: {page}, size: {size}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 기본 쿼리
|
|
||||||
query = select(SocialUpload).where(
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid
|
|
||||||
)
|
|
||||||
|
|
||||||
count_query = select(func.count(SocialUpload.id)).where(
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid
|
|
||||||
)
|
|
||||||
|
|
||||||
# 필터 적용
|
|
||||||
if platform:
|
|
||||||
query = query.where(SocialUpload.platform == platform.value)
|
|
||||||
count_query = count_query.where(SocialUpload.platform == platform.value)
|
|
||||||
|
|
||||||
if status:
|
|
||||||
query = query.where(SocialUpload.status == status.value)
|
|
||||||
count_query = count_query.where(SocialUpload.status == status.value)
|
|
||||||
|
|
||||||
# 총 개수 조회
|
|
||||||
total_result = await session.execute(count_query)
|
|
||||||
total = total_result.scalar() or 0
|
|
||||||
|
|
||||||
# 페이지네이션 적용
|
|
||||||
query = (
|
|
||||||
query.order_by(SocialUpload.created_at.desc())
|
|
||||||
.offset((page - 1) * size)
|
|
||||||
.limit(size)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
uploads = result.scalars().all()
|
|
||||||
|
|
||||||
items = [
|
|
||||||
SocialUploadHistoryItem(
|
|
||||||
upload_id=upload.id,
|
|
||||||
video_id=upload.video_id,
|
|
||||||
social_account_id=upload.social_account_id,
|
|
||||||
upload_seq=upload.upload_seq,
|
|
||||||
platform=upload.platform,
|
|
||||||
status=upload.status,
|
|
||||||
title=upload.title,
|
|
||||||
platform_url=upload.platform_url,
|
|
||||||
created_at=upload.created_at,
|
|
||||||
uploaded_at=upload.uploaded_at,
|
|
||||||
)
|
|
||||||
for upload in uploads
|
|
||||||
]
|
|
||||||
|
|
||||||
return SocialUploadHistoryResponse(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
size=size,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{upload_id}/retry",
|
|
||||||
response_model=SocialUploadResponse,
|
|
||||||
summary="업로드 재시도",
|
|
||||||
description="실패한 업로드를 재시도합니다.",
|
|
||||||
)
|
|
||||||
async def retry_upload(
|
|
||||||
upload_id: int,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> SocialUploadResponse:
|
|
||||||
"""
|
|
||||||
업로드 재시도
|
|
||||||
|
|
||||||
실패한 업로드를 다시 시도합니다.
|
|
||||||
"""
|
|
||||||
logger.info(f"[UPLOAD_API] 재시도 요청 - upload_id: {upload_id}")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.id == upload_id,
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not upload:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="업로드 정보를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if upload.status not in [UploadStatus.FAILED.value, UploadStatus.CANCELLED.value]:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="실패하거나 취소된 업로드만 재시도할 수 있습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 상태 초기화
|
|
||||||
upload.status = UploadStatus.PENDING.value
|
|
||||||
upload.upload_progress = 0
|
|
||||||
upload.error_message = None
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# 백그라운드 태스크 등록
|
|
||||||
background_tasks.add_task(process_social_upload, upload.id)
|
|
||||||
|
|
||||||
return SocialUploadResponse(
|
|
||||||
success=True,
|
|
||||||
upload_id=upload.id,
|
|
||||||
platform=upload.platform,
|
|
||||||
status=upload.status,
|
|
||||||
message="업로드 재시도가 요청되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{upload_id}",
|
|
||||||
response_model=MessageResponse,
|
|
||||||
summary="업로드 취소",
|
|
||||||
description="대기 중인 업로드를 취소합니다.",
|
|
||||||
)
|
|
||||||
async def cancel_upload(
|
|
||||||
upload_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> MessageResponse:
|
|
||||||
"""
|
|
||||||
업로드 취소
|
|
||||||
|
|
||||||
대기 중인 업로드를 취소합니다.
|
|
||||||
이미 진행 중이거나 완료된 업로드는 취소할 수 없습니다.
|
|
||||||
"""
|
|
||||||
logger.info(f"[UPLOAD_API] 취소 요청 - upload_id: {upload_id}")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.id == upload_id,
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not upload:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="업로드 정보를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if upload.status != UploadStatus.PENDING.value:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="대기 중인 업로드만 취소할 수 있습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
upload.status = UploadStatus.CANCELLED.value
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return MessageResponse(
|
|
||||||
success=True,
|
|
||||||
message="업로드가 취소되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
"""
|
|
||||||
Social Media Constants
|
|
||||||
|
|
||||||
소셜 미디어 플랫폼 관련 상수 및 Enum을 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class SocialPlatform(str, Enum):
|
|
||||||
"""지원하는 소셜 미디어 플랫폼"""
|
|
||||||
|
|
||||||
YOUTUBE = "youtube"
|
|
||||||
INSTAGRAM = "instagram"
|
|
||||||
FACEBOOK = "facebook"
|
|
||||||
TIKTOK = "tiktok"
|
|
||||||
|
|
||||||
|
|
||||||
class UploadStatus(str, Enum):
|
|
||||||
"""업로드 상태"""
|
|
||||||
|
|
||||||
PENDING = "pending" # 업로드 대기 중
|
|
||||||
UPLOADING = "uploading" # 업로드 진행 중
|
|
||||||
PROCESSING = "processing" # 플랫폼에서 처리 중 (인코딩 등)
|
|
||||||
COMPLETED = "completed" # 업로드 완료
|
|
||||||
FAILED = "failed" # 업로드 실패
|
|
||||||
CANCELLED = "cancelled" # 취소됨
|
|
||||||
|
|
||||||
|
|
||||||
class PrivacyStatus(str, Enum):
|
|
||||||
"""영상 공개 상태"""
|
|
||||||
|
|
||||||
PUBLIC = "public" # 전체 공개
|
|
||||||
UNLISTED = "unlisted" # 일부 공개 (링크 있는 사람만)
|
|
||||||
PRIVATE = "private" # 비공개
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 플랫폼별 설정
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
PLATFORM_CONFIG = {
|
|
||||||
SocialPlatform.YOUTUBE: {
|
|
||||||
"name": "YouTube",
|
|
||||||
"display_name": "유튜브",
|
|
||||||
"max_file_size_mb": 256000, # 256GB
|
|
||||||
"supported_formats": ["mp4", "mov", "avi", "wmv", "flv", "3gp", "webm"],
|
|
||||||
"max_title_length": 100,
|
|
||||||
"max_description_length": 5000,
|
|
||||||
"max_tags": 500,
|
|
||||||
"supported_privacy": ["public", "unlisted", "private"],
|
|
||||||
"requires_channel": True,
|
|
||||||
},
|
|
||||||
SocialPlatform.INSTAGRAM: {
|
|
||||||
"name": "Instagram",
|
|
||||||
"display_name": "인스타그램",
|
|
||||||
"max_file_size_mb": 4096, # 4GB (Reels)
|
|
||||||
"supported_formats": ["mp4", "mov"],
|
|
||||||
"max_duration_seconds": 90, # Reels 최대 90초
|
|
||||||
"min_duration_seconds": 3,
|
|
||||||
"aspect_ratios": ["9:16", "1:1", "4:5"],
|
|
||||||
"max_caption_length": 2200,
|
|
||||||
"requires_business_account": True,
|
|
||||||
},
|
|
||||||
SocialPlatform.FACEBOOK: {
|
|
||||||
"name": "Facebook",
|
|
||||||
"display_name": "페이스북",
|
|
||||||
"max_file_size_mb": 10240, # 10GB
|
|
||||||
"supported_formats": ["mp4", "mov"],
|
|
||||||
"max_duration_seconds": 14400, # 4시간
|
|
||||||
"max_title_length": 255,
|
|
||||||
"max_description_length": 5000,
|
|
||||||
"requires_page": True,
|
|
||||||
},
|
|
||||||
SocialPlatform.TIKTOK: {
|
|
||||||
"name": "TikTok",
|
|
||||||
"display_name": "틱톡",
|
|
||||||
"max_file_size_mb": 4096, # 4GB
|
|
||||||
"supported_formats": ["mp4", "mov", "webm"],
|
|
||||||
"max_duration_seconds": 600, # 10분
|
|
||||||
"min_duration_seconds": 1,
|
|
||||||
"max_title_length": 150,
|
|
||||||
"requires_business_account": True,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# YouTube OAuth Scopes
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
YOUTUBE_SCOPES = [
|
|
||||||
"https://www.googleapis.com/auth/youtube.upload", # 영상 업로드
|
|
||||||
"https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기
|
|
||||||
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
|
||||||
]
|
|
||||||
|
|
||||||
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Instagram/Facebook OAuth Scopes (추후 구현)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# INSTAGRAM_SCOPES = [
|
|
||||||
# "instagram_basic",
|
|
||||||
# "instagram_content_publish",
|
|
||||||
# "pages_read_engagement",
|
|
||||||
# "business_management",
|
|
||||||
# ]
|
|
||||||
|
|
||||||
# FACEBOOK_SCOPES = [
|
|
||||||
# "pages_manage_posts",
|
|
||||||
# "pages_read_engagement",
|
|
||||||
# "publish_video",
|
|
||||||
# "pages_show_list",
|
|
||||||
# ]
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# TikTok OAuth Scopes (추후 구현)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# TIKTOK_SCOPES = [
|
|
||||||
# "user.info.basic",
|
|
||||||
# "video.upload",
|
|
||||||
# "video.publish",
|
|
||||||
# ]
|
|
||||||
|
|
@ -1,331 +0,0 @@
|
||||||
"""
|
|
||||||
Social Media Exceptions
|
|
||||||
|
|
||||||
소셜 미디어 연동 관련 예외 클래스를 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import status
|
|
||||||
|
|
||||||
|
|
||||||
class SocialException(Exception):
|
|
||||||
"""소셜 미디어 기본 예외"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str,
|
|
||||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
code: str = "SOCIAL_ERROR",
|
|
||||||
):
|
|
||||||
self.message = message
|
|
||||||
self.status_code = status_code
|
|
||||||
self.code = code
|
|
||||||
super().__init__(self.message)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# OAuth 관련 예외
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthException(SocialException):
|
|
||||||
"""OAuth 관련 예외 기본 클래스"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str = "OAuth 인증 중 오류가 발생했습니다.",
|
|
||||||
status_code: int = status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code: str = "OAUTH_ERROR",
|
|
||||||
):
|
|
||||||
super().__init__(message, status_code, code)
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidStateError(OAuthException):
|
|
||||||
"""CSRF state 토큰 불일치"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "유효하지 않은 인증 세션입니다. 다시 시도해주세요."):
|
|
||||||
super().__init__(
|
|
||||||
message=message,
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
code="INVALID_STATE",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthStateExpiredError(OAuthException):
|
|
||||||
"""OAuth state 토큰 만료"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "인증 세션이 만료되었습니다. 다시 시도해주세요."):
|
|
||||||
super().__init__(
|
|
||||||
message=message,
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
code="STATE_EXPIRED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthTokenError(OAuthException):
|
|
||||||
"""OAuth 토큰 교환 실패"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str, message: str = ""):
|
|
||||||
error_message = f"{platform} 토큰 발급에 실패했습니다."
|
|
||||||
if message:
|
|
||||||
error_message += f" ({message})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code="TOKEN_EXCHANGE_FAILED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TokenRefreshError(OAuthException):
|
|
||||||
"""토큰 갱신 실패"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str):
|
|
||||||
super().__init__(
|
|
||||||
message=f"{platform} 토큰 갱신에 실패했습니다. 재연동이 필요합니다.",
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code="TOKEN_REFRESH_FAILED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthCodeExchangeError(OAuthException):
|
|
||||||
"""OAuth 인가 코드 교환 실패"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str, detail: str = ""):
|
|
||||||
error_message = f"{platform} 인가 코드 교환에 실패했습니다."
|
|
||||||
if detail:
|
|
||||||
error_message += f" ({detail})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code="CODE_EXCHANGE_FAILED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthTokenRefreshError(OAuthException):
|
|
||||||
"""OAuth 토큰 갱신 실패"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str, detail: str = ""):
|
|
||||||
error_message = f"{platform} 토큰 갱신에 실패했습니다."
|
|
||||||
if detail:
|
|
||||||
error_message += f" ({detail})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code="TOKEN_REFRESH_FAILED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TokenExpiredError(OAuthException):
|
|
||||||
"""토큰 만료"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str):
|
|
||||||
super().__init__(
|
|
||||||
message=f"{platform} 인증이 만료되었습니다. 재연동이 필요합니다.",
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code="TOKEN_EXPIRED",
|
|
||||||
)
|
|
||||||
self.platform = platform
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 소셜 계정 관련 예외
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountException(SocialException):
|
|
||||||
"""소셜 계정 관련 예외 기본 클래스"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountNotFoundError(SocialAccountException):
|
|
||||||
"""연동된 계정을 찾을 수 없음"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str = ""):
|
|
||||||
message = f"{platform} 계정이 연동되어 있지 않습니다." if platform else "연동된 소셜 계정이 없습니다."
|
|
||||||
super().__init__(
|
|
||||||
message=message,
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
code="SOCIAL_ACCOUNT_NOT_FOUND",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountAlreadyExistsError(SocialAccountException):
|
|
||||||
"""이미 연동된 계정이 존재함"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str):
|
|
||||||
super().__init__(
|
|
||||||
message=f"이미 {platform} 계정이 연동되어 있습니다.",
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
code="SOCIAL_ACCOUNT_EXISTS",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Alias for backward compatibility
|
|
||||||
SocialAccountAlreadyConnectedError = SocialAccountAlreadyExistsError
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountInactiveError(SocialAccountException):
|
|
||||||
"""비활성화된 소셜 계정"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str):
|
|
||||||
super().__init__(
|
|
||||||
message=f"{platform} 계정이 비활성화 상태입니다. 재연동이 필요합니다.",
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
code="SOCIAL_ACCOUNT_INACTIVE",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountError(SocialAccountException):
|
|
||||||
"""소셜 계정 일반 오류"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str, detail: str = ""):
|
|
||||||
error_message = f"{platform} 계정 처리 중 오류가 발생했습니다."
|
|
||||||
if detail:
|
|
||||||
error_message += f" ({detail})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
code="SOCIAL_ACCOUNT_ERROR",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 업로드 관련 예외
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class UploadException(SocialException):
|
|
||||||
"""업로드 관련 예외 기본 클래스"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UploadError(UploadException):
|
|
||||||
"""업로드 일반 오류"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str, detail: str = ""):
|
|
||||||
error_message = f"{platform} 업로드 중 오류가 발생했습니다."
|
|
||||||
if detail:
|
|
||||||
error_message += f" ({detail})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
code="UPLOAD_ERROR",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UploadValidationError(UploadException):
|
|
||||||
"""업로드 유효성 검사 실패"""
|
|
||||||
|
|
||||||
def __init__(self, message: str):
|
|
||||||
super().__init__(
|
|
||||||
message=message,
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
code="UPLOAD_VALIDATION_FAILED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VideoNotFoundError(UploadException):
|
|
||||||
"""영상을 찾을 수 없음"""
|
|
||||||
|
|
||||||
def __init__(self, video_id: int, detail: str = ""):
|
|
||||||
message = f"영상을 찾을 수 없습니다. (video_id: {video_id})"
|
|
||||||
if detail:
|
|
||||||
message = detail
|
|
||||||
super().__init__(
|
|
||||||
message=message,
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
code="VIDEO_NOT_FOUND",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VideoNotReadyError(UploadException):
|
|
||||||
"""영상이 준비되지 않음"""
|
|
||||||
|
|
||||||
def __init__(self, video_id: int):
|
|
||||||
super().__init__(
|
|
||||||
message=f"영상이 아직 준비되지 않았습니다. (video_id: {video_id})",
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
code="VIDEO_NOT_READY",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UploadFailedError(UploadException):
|
|
||||||
"""업로드 실패"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str, message: str = ""):
|
|
||||||
error_message = f"{platform} 업로드에 실패했습니다."
|
|
||||||
if message:
|
|
||||||
error_message += f" ({message})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
code="UPLOAD_FAILED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UploadQuotaExceededError(UploadException):
|
|
||||||
"""업로드 할당량 초과"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str):
|
|
||||||
super().__init__(
|
|
||||||
message=f"{platform} 일일 업로드 할당량이 초과되었습니다.",
|
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
code="UPLOAD_QUOTA_EXCEEDED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UploadNotFoundError(UploadException):
|
|
||||||
"""업로드 기록을 찾을 수 없음"""
|
|
||||||
|
|
||||||
def __init__(self, upload_id: int):
|
|
||||||
super().__init__(
|
|
||||||
message=f"업로드 기록을 찾을 수 없습니다. (upload_id: {upload_id})",
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
code="UPLOAD_NOT_FOUND",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 플랫폼 API 관련 예외
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class PlatformAPIError(SocialException):
|
|
||||||
"""플랫폼 API 호출 오류"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str, message: str = ""):
|
|
||||||
error_message = f"{platform} API 호출 중 오류가 발생했습니다."
|
|
||||||
if message:
|
|
||||||
error_message += f" ({message})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
code="PLATFORM_API_ERROR",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimitError(PlatformAPIError):
|
|
||||||
"""API 요청 한도 초과"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str, retry_after: int | None = None):
|
|
||||||
message = f"{platform} API 요청 한도가 초과되었습니다."
|
|
||||||
if retry_after:
|
|
||||||
message += f" {retry_after}초 후에 다시 시도해주세요."
|
|
||||||
super().__init__(
|
|
||||||
platform=platform,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
self.retry_after = retry_after
|
|
||||||
self.code = "RATE_LIMIT_EXCEEDED"
|
|
||||||
|
|
||||||
|
|
||||||
class UnsupportedPlatformError(SocialException):
|
|
||||||
"""지원하지 않는 플랫폼"""
|
|
||||||
|
|
||||||
def __init__(self, platform: str):
|
|
||||||
super().__init__(
|
|
||||||
message=f"지원하지 않는 플랫폼입니다: {platform}",
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
code="UNSUPPORTED_PLATFORM",
|
|
||||||
)
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
"""
|
|
||||||
Social Media Models
|
|
||||||
|
|
||||||
소셜 미디어 업로드 관련 SQLAlchemy 모델을 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
|
|
||||||
from sqlalchemy.dialects.mysql import JSON
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.database.session import Base
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.user.models import SocialAccount
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
|
|
||||||
class SocialUpload(Base):
|
|
||||||
"""
|
|
||||||
소셜 미디어 업로드 기록 테이블
|
|
||||||
|
|
||||||
영상의 소셜 미디어 플랫폼별 업로드 상태를 추적합니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: 고유 식별자 (자동 증가)
|
|
||||||
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
|
||||||
video_id: Video 외래키
|
|
||||||
social_account_id: SocialAccount 외래키
|
|
||||||
upload_seq: 업로드 순번 (동일 영상+채널 조합 내 순번, 관리자 추적용)
|
|
||||||
platform: 플랫폼 구분 (youtube, instagram, facebook, tiktok)
|
|
||||||
status: 업로드 상태 (pending, uploading, processing, completed, failed)
|
|
||||||
upload_progress: 업로드 진행률 (0-100)
|
|
||||||
platform_video_id: 플랫폼에서 부여한 영상 ID
|
|
||||||
platform_url: 플랫폼에서의 영상 URL
|
|
||||||
title: 영상 제목
|
|
||||||
description: 영상 설명
|
|
||||||
tags: 태그 목록 (JSON)
|
|
||||||
privacy_status: 공개 상태 (public, unlisted, private)
|
|
||||||
platform_options: 플랫폼별 추가 옵션 (JSON)
|
|
||||||
error_message: 에러 메시지 (실패 시)
|
|
||||||
retry_count: 재시도 횟수
|
|
||||||
uploaded_at: 업로드 완료 시간
|
|
||||||
created_at: 생성 일시
|
|
||||||
updated_at: 수정 일시
|
|
||||||
|
|
||||||
Relationships:
|
|
||||||
video: 연결된 Video
|
|
||||||
social_account: 연결된 SocialAccount
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "social_upload"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_social_upload_user_uuid", "user_uuid"),
|
|
||||||
Index("idx_social_upload_video_id", "video_id"),
|
|
||||||
Index("idx_social_upload_social_account_id", "social_account_id"),
|
|
||||||
Index("idx_social_upload_platform", "platform"),
|
|
||||||
Index("idx_social_upload_status", "status"),
|
|
||||||
Index("idx_social_upload_created_at", "created_at"),
|
|
||||||
# 동일 영상+채널 조합 조회용 인덱스 (유니크 아님 - 여러 번 업로드 가능)
|
|
||||||
Index("idx_social_upload_video_account", "video_id", "social_account_id"),
|
|
||||||
# 순번 조회용 인덱스
|
|
||||||
Index("idx_social_upload_seq", "video_id", "social_account_id", "upload_seq"),
|
|
||||||
{
|
|
||||||
"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="고유 식별자",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 관계 필드
|
|
||||||
# ==========================================================================
|
|
||||||
user_uuid: Mapped[str] = mapped_column(
|
|
||||||
String(36),
|
|
||||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="사용자 UUID (User.user_uuid 참조)",
|
|
||||||
)
|
|
||||||
|
|
||||||
video_id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("video.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="Video 외래키",
|
|
||||||
)
|
|
||||||
|
|
||||||
social_account_id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("social_account.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="SocialAccount 외래키",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 업로드 순번 (관리자 추적용)
|
|
||||||
# ==========================================================================
|
|
||||||
upload_seq: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
default=1,
|
|
||||||
comment="업로드 순번 (동일 영상+채널 조합 내 순번, 1부터 시작)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 플랫폼 정보
|
|
||||||
# ==========================================================================
|
|
||||||
platform: Mapped[str] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=False,
|
|
||||||
comment="플랫폼 구분 (youtube, instagram, facebook, tiktok)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 업로드 상태
|
|
||||||
# ==========================================================================
|
|
||||||
status: Mapped[str] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=False,
|
|
||||||
default="pending",
|
|
||||||
comment="업로드 상태 (pending, uploading, processing, completed, failed)",
|
|
||||||
)
|
|
||||||
|
|
||||||
upload_progress: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
default=0,
|
|
||||||
comment="업로드 진행률 (0-100)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 플랫폼 결과
|
|
||||||
# ==========================================================================
|
|
||||||
platform_video_id: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(100),
|
|
||||||
nullable=True,
|
|
||||||
comment="플랫폼에서 부여한 영상 ID",
|
|
||||||
)
|
|
||||||
|
|
||||||
platform_url: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(500),
|
|
||||||
nullable=True,
|
|
||||||
comment="플랫폼에서의 영상 URL",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 메타데이터
|
|
||||||
# ==========================================================================
|
|
||||||
title: Mapped[str] = mapped_column(
|
|
||||||
String(200),
|
|
||||||
nullable=False,
|
|
||||||
comment="영상 제목",
|
|
||||||
)
|
|
||||||
|
|
||||||
description: Mapped[Optional[str]] = mapped_column(
|
|
||||||
Text,
|
|
||||||
nullable=True,
|
|
||||||
comment="영상 설명",
|
|
||||||
)
|
|
||||||
|
|
||||||
tags: Mapped[Optional[dict]] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=True,
|
|
||||||
comment="태그 목록 (JSON 배열)",
|
|
||||||
)
|
|
||||||
|
|
||||||
privacy_status: Mapped[str] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=False,
|
|
||||||
default="private",
|
|
||||||
comment="공개 상태 (public, unlisted, private)",
|
|
||||||
)
|
|
||||||
|
|
||||||
platform_options: Mapped[Optional[dict]] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=True,
|
|
||||||
comment="플랫폼별 추가 옵션 (JSON)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 에러 정보
|
|
||||||
# ==========================================================================
|
|
||||||
error_message: Mapped[Optional[str]] = mapped_column(
|
|
||||||
Text,
|
|
||||||
nullable=True,
|
|
||||||
comment="에러 메시지 (실패 시)",
|
|
||||||
)
|
|
||||||
|
|
||||||
retry_count: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
default=0,
|
|
||||||
comment="재시도 횟수",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 시간 정보
|
|
||||||
# ==========================================================================
|
|
||||||
uploaded_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="수정 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# Relationships
|
|
||||||
# ==========================================================================
|
|
||||||
video: Mapped["Video"] = relationship(
|
|
||||||
"Video",
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
|
|
||||||
social_account: Mapped["SocialAccount"] = relationship(
|
|
||||||
"SocialAccount",
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<SocialUpload("
|
|
||||||
f"id={self.id}, "
|
|
||||||
f"video_id={self.video_id}, "
|
|
||||||
f"account_id={self.social_account_id}, "
|
|
||||||
f"seq={self.upload_seq}, "
|
|
||||||
f"platform='{self.platform}', "
|
|
||||||
f"status='{self.status}'"
|
|
||||||
f")>"
|
|
||||||
)
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""
|
|
||||||
Social OAuth Module
|
|
||||||
|
|
||||||
소셜 미디어 OAuth 클라이언트 모듈입니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.social.constants import SocialPlatform
|
|
||||||
from app.social.oauth.base import BaseOAuthClient
|
|
||||||
|
|
||||||
|
|
||||||
def get_oauth_client(platform: SocialPlatform) -> BaseOAuthClient:
|
|
||||||
"""
|
|
||||||
플랫폼에 맞는 OAuth 클라이언트 반환
|
|
||||||
|
|
||||||
Args:
|
|
||||||
platform: 소셜 플랫폼
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BaseOAuthClient: OAuth 클라이언트 인스턴스
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 지원하지 않는 플랫폼인 경우
|
|
||||||
"""
|
|
||||||
if platform == SocialPlatform.YOUTUBE:
|
|
||||||
from app.social.oauth.youtube import YouTubeOAuthClient
|
|
||||||
|
|
||||||
return YouTubeOAuthClient()
|
|
||||||
|
|
||||||
# 추후 확장
|
|
||||||
# elif platform == SocialPlatform.INSTAGRAM:
|
|
||||||
# from app.social.oauth.instagram import InstagramOAuthClient
|
|
||||||
# return InstagramOAuthClient()
|
|
||||||
# elif platform == SocialPlatform.FACEBOOK:
|
|
||||||
# from app.social.oauth.facebook import FacebookOAuthClient
|
|
||||||
# return FacebookOAuthClient()
|
|
||||||
# elif platform == SocialPlatform.TIKTOK:
|
|
||||||
# from app.social.oauth.tiktok import TikTokOAuthClient
|
|
||||||
# return TikTokOAuthClient()
|
|
||||||
|
|
||||||
raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}")
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"BaseOAuthClient",
|
|
||||||
"get_oauth_client",
|
|
||||||
]
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
"""
|
|
||||||
Base OAuth Client
|
|
||||||
|
|
||||||
소셜 미디어 OAuth 클라이언트의 추상 기본 클래스입니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from app.social.constants import SocialPlatform
|
|
||||||
from app.social.schemas import OAuthTokenResponse, PlatformUserInfo
|
|
||||||
|
|
||||||
|
|
||||||
class BaseOAuthClient(ABC):
|
|
||||||
"""
|
|
||||||
소셜 미디어 OAuth 클라이언트 추상 기본 클래스
|
|
||||||
|
|
||||||
모든 플랫폼별 OAuth 클라이언트는 이 클래스를 상속받아 구현합니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
platform: 소셜 플랫폼 종류
|
|
||||||
"""
|
|
||||||
|
|
||||||
platform: SocialPlatform
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_authorization_url(self, state: str) -> str:
|
|
||||||
"""
|
|
||||||
OAuth 인증 URL 생성
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: CSRF 방지용 state 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: OAuth 인증 페이지 URL
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
|
||||||
"""
|
|
||||||
인가 코드로 액세스 토큰 교환
|
|
||||||
|
|
||||||
Args:
|
|
||||||
code: OAuth 인가 코드
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OAuthTokenResponse: 액세스 토큰 및 리프레시 토큰
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
OAuthCodeExchangeError: 토큰 교환 실패 시
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def refresh_token(self, refresh_token: str) -> OAuthTokenResponse:
|
|
||||||
"""
|
|
||||||
리프레시 토큰으로 액세스 토큰 갱신
|
|
||||||
|
|
||||||
Args:
|
|
||||||
refresh_token: 리프레시 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OAuthTokenResponse: 새 액세스 토큰
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
OAuthTokenRefreshError: 토큰 갱신 실패 시
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_user_info(self, access_token: str) -> PlatformUserInfo:
|
|
||||||
"""
|
|
||||||
플랫폼 사용자 정보 조회
|
|
||||||
|
|
||||||
Args:
|
|
||||||
access_token: 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PlatformUserInfo: 플랫폼 사용자 정보
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
SocialAccountError: 사용자 정보 조회 실패 시
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def revoke_token(self, token: str) -> bool:
|
|
||||||
"""
|
|
||||||
토큰 폐기 (연동 해제 시)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token: 폐기할 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 폐기 성공 여부
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def is_token_expired(self, expires_in: Optional[int]) -> bool:
|
|
||||||
"""
|
|
||||||
토큰 만료 여부 확인 (만료 10분 전이면 True)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
expires_in: 토큰 만료까지 남은 시간(초)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 갱신 필요 여부
|
|
||||||
"""
|
|
||||||
if expires_in is None:
|
|
||||||
return False
|
|
||||||
# 만료 10분(600초) 전이면 갱신 필요
|
|
||||||
return expires_in <= 600
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
"""
|
|
||||||
YouTube OAuth Client
|
|
||||||
|
|
||||||
Google OAuth를 사용한 YouTube 인증 클라이언트입니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from config import social_oauth_settings
|
|
||||||
from app.social.constants import SocialPlatform, YOUTUBE_SCOPES
|
|
||||||
from app.social.exceptions import (
|
|
||||||
OAuthCodeExchangeError,
|
|
||||||
OAuthTokenRefreshError,
|
|
||||||
SocialAccountError,
|
|
||||||
)
|
|
||||||
from app.social.oauth.base import BaseOAuthClient
|
|
||||||
from app.social.schemas import OAuthTokenResponse, PlatformUserInfo
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeOAuthClient(BaseOAuthClient):
|
|
||||||
"""
|
|
||||||
YouTube OAuth 클라이언트
|
|
||||||
|
|
||||||
Google OAuth 2.0을 사용하여 YouTube 계정 인증을 처리합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
platform = SocialPlatform.YOUTUBE
|
|
||||||
|
|
||||||
# Google OAuth 엔드포인트
|
|
||||||
AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
||||||
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
||||||
USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
|
||||||
YOUTUBE_CHANNEL_URL = "https://www.googleapis.com/youtube/v3/channels"
|
|
||||||
REVOKE_URL = "https://oauth2.googleapis.com/revoke"
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.client_id = social_oauth_settings.YOUTUBE_CLIENT_ID
|
|
||||||
self.client_secret = social_oauth_settings.YOUTUBE_CLIENT_SECRET
|
|
||||||
self.redirect_uri = social_oauth_settings.YOUTUBE_REDIRECT_URI
|
|
||||||
|
|
||||||
def get_authorization_url(self, state: str) -> str:
|
|
||||||
"""
|
|
||||||
Google OAuth 인증 URL 생성
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: CSRF 방지용 state 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Google OAuth 인증 페이지 URL
|
|
||||||
"""
|
|
||||||
params = {
|
|
||||||
"client_id": self.client_id,
|
|
||||||
"redirect_uri": self.redirect_uri,
|
|
||||||
"response_type": "code",
|
|
||||||
"scope": " ".join(YOUTUBE_SCOPES),
|
|
||||||
"access_type": "offline", # refresh_token 받기 위해 필요
|
|
||||||
"prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만)
|
|
||||||
"state": state,
|
|
||||||
}
|
|
||||||
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
|
||||||
logger.debug(f"[YOUTUBE_OAUTH] 인증 URL 생성: {url[:100]}...")
|
|
||||||
return url
|
|
||||||
|
|
||||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
|
||||||
"""
|
|
||||||
인가 코드로 액세스 토큰 교환
|
|
||||||
|
|
||||||
Args:
|
|
||||||
code: OAuth 인가 코드
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OAuthTokenResponse: 액세스 토큰 및 리프레시 토큰
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
OAuthCodeExchangeError: 토큰 교환 실패 시
|
|
||||||
"""
|
|
||||||
logger.info(f"[YOUTUBE_OAUTH] 토큰 교환 시작 - code: {code[:20]}...")
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"client_id": self.client_id,
|
|
||||||
"client_secret": self.client_secret,
|
|
||||||
"code": code,
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"redirect_uri": self.redirect_uri,
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
try:
|
|
||||||
response = await client.post(
|
|
||||||
self.TOKEN_URL,
|
|
||||||
data=data,
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
token_data = response.json()
|
|
||||||
|
|
||||||
logger.info("[YOUTUBE_OAUTH] 토큰 교환 성공")
|
|
||||||
logger.debug(
|
|
||||||
f"[YOUTUBE_OAUTH] 토큰 정보 - "
|
|
||||||
f"expires_in: {token_data.get('expires_in')}, "
|
|
||||||
f"scope: {token_data.get('scope')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return OAuthTokenResponse(
|
|
||||||
access_token=token_data["access_token"],
|
|
||||||
refresh_token=token_data.get("refresh_token"),
|
|
||||||
expires_in=token_data["expires_in"],
|
|
||||||
token_type=token_data.get("token_type", "Bearer"),
|
|
||||||
scope=token_data.get("scope"),
|
|
||||||
)
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
error_detail = e.response.text if e.response else str(e)
|
|
||||||
logger.error(
|
|
||||||
f"[YOUTUBE_OAUTH] 토큰 교환 실패 - "
|
|
||||||
f"status: {e.response.status_code}, error: {error_detail}"
|
|
||||||
)
|
|
||||||
raise OAuthCodeExchangeError(
|
|
||||||
platform=self.platform.value,
|
|
||||||
detail=f"토큰 교환 실패: {error_detail}",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[YOUTUBE_OAUTH] 토큰 교환 중 예외 발생: {e}")
|
|
||||||
raise OAuthCodeExchangeError(
|
|
||||||
platform=self.platform.value,
|
|
||||||
detail=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def refresh_token(self, refresh_token: str) -> OAuthTokenResponse:
|
|
||||||
"""
|
|
||||||
리프레시 토큰으로 액세스 토큰 갱신
|
|
||||||
|
|
||||||
Args:
|
|
||||||
refresh_token: 리프레시 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OAuthTokenResponse: 새 액세스 토큰
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
OAuthTokenRefreshError: 토큰 갱신 실패 시
|
|
||||||
"""
|
|
||||||
logger.info("[YOUTUBE_OAUTH] 토큰 갱신 시작")
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"client_id": self.client_id,
|
|
||||||
"client_secret": self.client_secret,
|
|
||||||
"refresh_token": refresh_token,
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
try:
|
|
||||||
response = await client.post(
|
|
||||||
self.TOKEN_URL,
|
|
||||||
data=data,
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
token_data = response.json()
|
|
||||||
|
|
||||||
logger.info("[YOUTUBE_OAUTH] 토큰 갱신 성공")
|
|
||||||
|
|
||||||
return OAuthTokenResponse(
|
|
||||||
access_token=token_data["access_token"],
|
|
||||||
refresh_token=refresh_token, # Google은 refresh_token 재발급 안함
|
|
||||||
expires_in=token_data["expires_in"],
|
|
||||||
token_type=token_data.get("token_type", "Bearer"),
|
|
||||||
scope=token_data.get("scope"),
|
|
||||||
)
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
error_detail = e.response.text if e.response else str(e)
|
|
||||||
logger.error(
|
|
||||||
f"[YOUTUBE_OAUTH] 토큰 갱신 실패 - "
|
|
||||||
f"status: {e.response.status_code}, error: {error_detail}"
|
|
||||||
)
|
|
||||||
raise OAuthTokenRefreshError(
|
|
||||||
platform=self.platform.value,
|
|
||||||
detail=f"토큰 갱신 실패: {error_detail}",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[YOUTUBE_OAUTH] 토큰 갱신 중 예외 발생: {e}")
|
|
||||||
raise OAuthTokenRefreshError(
|
|
||||||
platform=self.platform.value,
|
|
||||||
detail=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_user_info(self, access_token: str) -> PlatformUserInfo:
|
|
||||||
"""
|
|
||||||
YouTube 채널 정보 조회
|
|
||||||
|
|
||||||
Args:
|
|
||||||
access_token: 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PlatformUserInfo: YouTube 채널 정보
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
SocialAccountError: 정보 조회 실패 시
|
|
||||||
"""
|
|
||||||
logger.info("[YOUTUBE_OAUTH] 사용자/채널 정보 조회 시작")
|
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
try:
|
|
||||||
# 1. Google 사용자 기본 정보 조회
|
|
||||||
userinfo_response = await client.get(
|
|
||||||
self.USERINFO_URL,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
userinfo_response.raise_for_status()
|
|
||||||
userinfo = userinfo_response.json()
|
|
||||||
|
|
||||||
# 2. YouTube 채널 정보 조회
|
|
||||||
channel_params = {
|
|
||||||
"part": "snippet,statistics",
|
|
||||||
"mine": "true",
|
|
||||||
}
|
|
||||||
channel_response = await client.get(
|
|
||||||
self.YOUTUBE_CHANNEL_URL,
|
|
||||||
headers=headers,
|
|
||||||
params=channel_params,
|
|
||||||
)
|
|
||||||
channel_response.raise_for_status()
|
|
||||||
channel_data = channel_response.json()
|
|
||||||
|
|
||||||
# 채널이 없는 경우
|
|
||||||
if not channel_data.get("items"):
|
|
||||||
logger.warning("[YOUTUBE_OAUTH] YouTube 채널 없음")
|
|
||||||
raise SocialAccountError(
|
|
||||||
platform=self.platform.value,
|
|
||||||
detail="YouTube 채널이 없습니다. 채널을 먼저 생성해주세요.",
|
|
||||||
)
|
|
||||||
|
|
||||||
channel = channel_data["items"][0]
|
|
||||||
snippet = channel.get("snippet", {})
|
|
||||||
statistics = channel.get("statistics", {})
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[YOUTUBE_OAUTH] 채널 정보 조회 성공 - "
|
|
||||||
f"channel_id: {channel['id']}, "
|
|
||||||
f"title: {snippet.get('title')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return PlatformUserInfo(
|
|
||||||
platform_user_id=channel["id"],
|
|
||||||
username=snippet.get("customUrl"), # @username 형태
|
|
||||||
display_name=snippet.get("title"),
|
|
||||||
profile_image_url=snippet.get("thumbnails", {})
|
|
||||||
.get("default", {})
|
|
||||||
.get("url"),
|
|
||||||
platform_data={
|
|
||||||
"channel_id": channel["id"],
|
|
||||||
"channel_title": snippet.get("title"),
|
|
||||||
"channel_description": snippet.get("description"),
|
|
||||||
"custom_url": snippet.get("customUrl"),
|
|
||||||
"subscriber_count": statistics.get("subscriberCount"),
|
|
||||||
"video_count": statistics.get("videoCount"),
|
|
||||||
"view_count": statistics.get("viewCount"),
|
|
||||||
"google_user_id": userinfo.get("id"),
|
|
||||||
"google_email": userinfo.get("email"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
error_detail = e.response.text if e.response else str(e)
|
|
||||||
logger.error(
|
|
||||||
f"[YOUTUBE_OAUTH] 정보 조회 실패 - "
|
|
||||||
f"status: {e.response.status_code}, error: {error_detail}"
|
|
||||||
)
|
|
||||||
raise SocialAccountError(
|
|
||||||
platform=self.platform.value,
|
|
||||||
detail=f"사용자 정보 조회 실패: {error_detail}",
|
|
||||||
)
|
|
||||||
except SocialAccountError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[YOUTUBE_OAUTH] 정보 조회 중 예외 발생: {e}")
|
|
||||||
raise SocialAccountError(
|
|
||||||
platform=self.platform.value,
|
|
||||||
detail=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def revoke_token(self, token: str) -> bool:
|
|
||||||
"""
|
|
||||||
토큰 폐기 (연동 해제 시)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token: 폐기할 토큰 (access_token 또는 refresh_token)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 폐기 성공 여부
|
|
||||||
"""
|
|
||||||
logger.info("[YOUTUBE_OAUTH] 토큰 폐기 시작")
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
try:
|
|
||||||
response = await client.post(
|
|
||||||
self.REVOKE_URL,
|
|
||||||
data={"token": token},
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
logger.info("[YOUTUBE_OAUTH] 토큰 폐기 성공")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"[YOUTUBE_OAUTH] 토큰 폐기 실패 - "
|
|
||||||
f"status: {response.status_code}, body: {response.text}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[YOUTUBE_OAUTH] 토큰 폐기 중 예외 발생: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# 싱글톤 인스턴스
|
|
||||||
youtube_oauth_client = YouTubeOAuthClient()
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
"""
|
|
||||||
Social Media Schemas
|
|
||||||
|
|
||||||
소셜 미디어 연동 관련 Pydantic 스키마를 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.social.constants import PrivacyStatus, SocialPlatform, UploadStatus
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# OAuth 관련 스키마
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class SocialConnectResponse(BaseModel):
|
|
||||||
"""소셜 계정 연동 시작 응답"""
|
|
||||||
|
|
||||||
auth_url: str = Field(..., description="OAuth 인증 URL")
|
|
||||||
state: str = Field(..., description="CSRF 방지용 state 토큰")
|
|
||||||
platform: str = Field(..., description="플랫폼명")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
|
|
||||||
"state": "abc123xyz",
|
|
||||||
"platform": "youtube",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountResponse(BaseModel):
|
|
||||||
"""연동된 소셜 계정 정보"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="소셜 계정 ID")
|
|
||||||
platform: str = Field(..., description="플랫폼명")
|
|
||||||
platform_user_id: str = Field(..., description="플랫폼 내 사용자 ID")
|
|
||||||
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명")
|
|
||||||
display_name: Optional[str] = Field(None, description="표시 이름")
|
|
||||||
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
|
|
||||||
is_active: bool = Field(..., description="활성화 상태")
|
|
||||||
connected_at: datetime = Field(..., description="연동 일시")
|
|
||||||
platform_data: Optional[dict[str, Any]] = Field(
|
|
||||||
None, description="플랫폼별 추가 정보 (채널ID, 구독자 수 등)"
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
from_attributes=True,
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"id": 1,
|
|
||||||
"platform": "youtube",
|
|
||||||
"platform_user_id": "UC1234567890",
|
|
||||||
"platform_username": "my_channel",
|
|
||||||
"display_name": "My Channel",
|
|
||||||
"profile_image_url": "https://...",
|
|
||||||
"is_active": True,
|
|
||||||
"connected_at": "2024-01-15T12:00:00",
|
|
||||||
"platform_data": {
|
|
||||||
"channel_id": "UC1234567890",
|
|
||||||
"channel_title": "My Channel",
|
|
||||||
"subscriber_count": 1000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountListResponse(BaseModel):
|
|
||||||
"""연동된 소셜 계정 목록 응답"""
|
|
||||||
|
|
||||||
accounts: list[SocialAccountResponse] = Field(..., description="연동 계정 목록")
|
|
||||||
total: int = Field(..., description="총 연동 계정 수")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"accounts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"platform": "youtube",
|
|
||||||
"platform_user_id": "UC1234567890",
|
|
||||||
"platform_username": "my_channel",
|
|
||||||
"display_name": "My Channel",
|
|
||||||
"is_active": True,
|
|
||||||
"connected_at": "2024-01-15T12:00:00",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 내부 사용 스키마 (OAuth 토큰 응답)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthTokenResponse(BaseModel):
|
|
||||||
"""OAuth 토큰 응답 (내부 사용)"""
|
|
||||||
|
|
||||||
access_token: str
|
|
||||||
refresh_token: Optional[str] = None
|
|
||||||
expires_in: int
|
|
||||||
token_type: str = "Bearer"
|
|
||||||
scope: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PlatformUserInfo(BaseModel):
|
|
||||||
"""플랫폼 사용자 정보 (내부 사용)"""
|
|
||||||
|
|
||||||
platform_user_id: str
|
|
||||||
username: Optional[str] = None
|
|
||||||
display_name: Optional[str] = None
|
|
||||||
profile_image_url: Optional[str] = None
|
|
||||||
platform_data: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 업로드 관련 스키마
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class SocialUploadRequest(BaseModel):
|
|
||||||
"""소셜 업로드 요청"""
|
|
||||||
|
|
||||||
video_id: int = Field(..., description="업로드할 영상 ID")
|
|
||||||
social_account_id: int = Field(..., description="업로드할 소셜 계정 ID (연동 계정 목록의 id)")
|
|
||||||
title: str = Field(..., min_length=1, max_length=100, description="영상 제목")
|
|
||||||
description: Optional[str] = Field(
|
|
||||||
None, max_length=5000, description="영상 설명"
|
|
||||||
)
|
|
||||||
tags: Optional[list[str]] = Field(None, description="태그 목록 (쉼표로 구분된 문자열도 가능)")
|
|
||||||
privacy_status: PrivacyStatus = Field(
|
|
||||||
default=PrivacyStatus.PRIVATE, description="공개 상태 (public, unlisted, private)"
|
|
||||||
)
|
|
||||||
scheduled_at: Optional[datetime] = Field(
|
|
||||||
None, description="예약 게시 시간 (없으면 즉시 게시)"
|
|
||||||
)
|
|
||||||
platform_options: Optional[dict[str, Any]] = Field(
|
|
||||||
None, description="플랫폼별 추가 옵션"
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"video_id": 123,
|
|
||||||
"social_account_id": 1,
|
|
||||||
"title": "도그앤조이 애견펜션 2026.02.02",
|
|
||||||
"description": "영상 설명입니다.",
|
|
||||||
"tags": ["여행", "vlog", "애견펜션"],
|
|
||||||
"privacy_status": "public",
|
|
||||||
"scheduled_at": "2026-02-02T15:00:00",
|
|
||||||
"platform_options": {
|
|
||||||
"category_id": "22", # YouTube 카테고리
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialUploadResponse(BaseModel):
|
|
||||||
"""소셜 업로드 요청 응답"""
|
|
||||||
|
|
||||||
success: bool = Field(..., description="요청 성공 여부")
|
|
||||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
|
||||||
platform: str = Field(..., description="플랫폼명")
|
|
||||||
status: str = Field(..., description="업로드 상태")
|
|
||||||
message: str = Field(..., description="응답 메시지")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"success": True,
|
|
||||||
"upload_id": 456,
|
|
||||||
"platform": "youtube",
|
|
||||||
"status": "pending",
|
|
||||||
"message": "업로드 요청이 접수되었습니다.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialUploadStatusResponse(BaseModel):
|
|
||||||
"""업로드 상태 조회 응답"""
|
|
||||||
|
|
||||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
|
||||||
video_id: int = Field(..., description="영상 ID")
|
|
||||||
social_account_id: int = Field(..., description="소셜 계정 ID")
|
|
||||||
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
|
|
||||||
platform: str = Field(..., description="플랫폼명")
|
|
||||||
status: UploadStatus = Field(..., description="업로드 상태")
|
|
||||||
upload_progress: int = Field(..., description="업로드 진행률 (0-100)")
|
|
||||||
title: str = Field(..., description="영상 제목")
|
|
||||||
platform_video_id: Optional[str] = Field(None, description="플랫폼 영상 ID")
|
|
||||||
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지")
|
|
||||||
retry_count: int = Field(default=0, description="재시도 횟수")
|
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
|
||||||
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
from_attributes=True,
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"upload_id": 456,
|
|
||||||
"video_id": 123,
|
|
||||||
"social_account_id": 1,
|
|
||||||
"upload_seq": 2,
|
|
||||||
"platform": "youtube",
|
|
||||||
"status": "completed",
|
|
||||||
"upload_progress": 100,
|
|
||||||
"title": "나의 첫 영상",
|
|
||||||
"platform_video_id": "dQw4w9WgXcQ",
|
|
||||||
"platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
||||||
"error_message": None,
|
|
||||||
"retry_count": 0,
|
|
||||||
"created_at": "2024-01-15T12:00:00",
|
|
||||||
"uploaded_at": "2024-01-15T12:05:00",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialUploadHistoryItem(BaseModel):
|
|
||||||
"""업로드 이력 아이템"""
|
|
||||||
|
|
||||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
|
||||||
video_id: int = Field(..., description="영상 ID")
|
|
||||||
social_account_id: int = Field(..., description="소셜 계정 ID")
|
|
||||||
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
|
|
||||||
platform: str = Field(..., description="플랫폼명")
|
|
||||||
status: str = Field(..., description="업로드 상태")
|
|
||||||
title: str = Field(..., description="영상 제목")
|
|
||||||
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
|
||||||
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialUploadHistoryResponse(BaseModel):
|
|
||||||
"""업로드 이력 목록 응답"""
|
|
||||||
|
|
||||||
items: list[SocialUploadHistoryItem] = Field(..., description="업로드 이력 목록")
|
|
||||||
total: int = Field(..., description="전체 개수")
|
|
||||||
page: int = Field(..., description="현재 페이지")
|
|
||||||
size: int = Field(..., description="페이지 크기")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"upload_id": 456,
|
|
||||||
"video_id": 123,
|
|
||||||
"platform": "youtube",
|
|
||||||
"status": "completed",
|
|
||||||
"title": "나의 첫 영상",
|
|
||||||
"platform_url": "https://www.youtube.com/watch?v=xxx",
|
|
||||||
"created_at": "2024-01-15T12:00:00",
|
|
||||||
"uploaded_at": "2024-01-15T12:05:00",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1,
|
|
||||||
"page": 1,
|
|
||||||
"size": 20,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
class YoutubeDescriptionRequest(BaseModel):
|
|
||||||
"""유튜브 SEO Description 제안 (자동완성) Request 모델"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"task_id" : "019c739f-65fc-7d15-8c88-b31be00e588e"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
|
|
||||||
class YoutubeDescriptionResponse(BaseModel):
|
|
||||||
"""유튜브 SEO Description 제안 (자동완성) Response 모델"""
|
|
||||||
title:str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화")
|
|
||||||
description : str = Field(..., description="제안된 유튜브 SEO Description")
|
|
||||||
keywords : list[str] = Field(..., description="해시태그 리스트")
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"title" : "여기에 더미 타이틀",
|
|
||||||
"description": "여기에 더미 텍스트",
|
|
||||||
"keywords": ["여기에", "더미", "해시태그"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 공통 응답 스키마
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(BaseModel):
|
|
||||||
"""단순 메시지 응답"""
|
|
||||||
|
|
||||||
success: bool = Field(..., description="성공 여부")
|
|
||||||
message: str = Field(..., description="응답 메시지")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"success": True,
|
|
||||||
"message": "작업이 완료되었습니다.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
@ -1,742 +0,0 @@
|
||||||
"""
|
|
||||||
Social Account Service
|
|
||||||
|
|
||||||
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.utils.timezone import now
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from redis.asyncio import Redis
|
|
||||||
|
|
||||||
from config import social_oauth_settings, db_settings
|
|
||||||
from app.social.constants import SocialPlatform
|
|
||||||
|
|
||||||
# Social OAuth용 Redis 클라이언트 (DB 2 사용)
|
|
||||||
redis_client = Redis(
|
|
||||||
host=db_settings.REDIS_HOST,
|
|
||||||
port=db_settings.REDIS_PORT,
|
|
||||||
db=2,
|
|
||||||
decode_responses=True,
|
|
||||||
)
|
|
||||||
from app.social.exceptions import (
|
|
||||||
OAuthStateExpiredError,
|
|
||||||
OAuthTokenRefreshError,
|
|
||||||
SocialAccountNotFoundError,
|
|
||||||
TokenExpiredError,
|
|
||||||
)
|
|
||||||
from app.social.oauth import get_oauth_client
|
|
||||||
from app.social.schemas import (
|
|
||||||
OAuthTokenResponse,
|
|
||||||
PlatformUserInfo,
|
|
||||||
SocialAccountResponse,
|
|
||||||
SocialConnectResponse,
|
|
||||||
)
|
|
||||||
from app.user.models import SocialAccount
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountService:
|
|
||||||
"""
|
|
||||||
소셜 계정 연동 서비스
|
|
||||||
|
|
||||||
OAuth 인증, 계정 연동/해제, 토큰 관리 기능을 제공합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Redis key prefix for OAuth state
|
|
||||||
STATE_KEY_PREFIX = "social:oauth:state:"
|
|
||||||
|
|
||||||
async def start_connect(
|
|
||||||
self,
|
|
||||||
user_uuid: str,
|
|
||||||
platform: SocialPlatform,
|
|
||||||
) -> SocialConnectResponse:
|
|
||||||
"""
|
|
||||||
소셜 계정 연동 시작
|
|
||||||
|
|
||||||
OAuth 인증 URL을 생성하고 state 토큰을 저장합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_uuid: 사용자 UUID
|
|
||||||
platform: 연동할 플랫폼
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SocialConnectResponse: OAuth 인증 URL 및 state 토큰
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL] 소셜 계정 연동 시작 - "
|
|
||||||
f"user_uuid: {user_uuid}, platform: {platform.value}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. state 토큰 생성 (CSRF 방지)
|
|
||||||
state = secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
# 2. state를 Redis에 저장 (user_uuid 포함)
|
|
||||||
state_key = f"{self.STATE_KEY_PREFIX}{state}"
|
|
||||||
state_data = {
|
|
||||||
"user_uuid": user_uuid,
|
|
||||||
"platform": platform.value,
|
|
||||||
}
|
|
||||||
await redis_client.setex(
|
|
||||||
state_key,
|
|
||||||
social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
|
|
||||||
json.dumps(state_data), # JSON으로 직렬화
|
|
||||||
)
|
|
||||||
logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}")
|
|
||||||
|
|
||||||
# 3. OAuth 클라이언트에서 인증 URL 생성
|
|
||||||
oauth_client = get_oauth_client(platform)
|
|
||||||
auth_url = oauth_client.get_authorization_url(state)
|
|
||||||
|
|
||||||
logger.info(f"[SOCIAL] OAuth URL 생성 완료 - platform: {platform.value}")
|
|
||||||
|
|
||||||
return SocialConnectResponse(
|
|
||||||
auth_url=auth_url,
|
|
||||||
state=state,
|
|
||||||
platform=platform.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_callback(
|
|
||||||
self,
|
|
||||||
code: str,
|
|
||||||
state: str,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> SocialAccountResponse:
|
|
||||||
"""
|
|
||||||
OAuth 콜백 처리
|
|
||||||
|
|
||||||
인가 코드로 토큰을 교환하고 소셜 계정을 저장합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
code: OAuth 인가 코드
|
|
||||||
state: CSRF 방지용 state 토큰
|
|
||||||
session: DB 세션
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SocialAccountResponse: 연동된 소셜 계정 정보
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
OAuthStateExpiredError: state 토큰이 만료되거나 유효하지 않은 경우
|
|
||||||
"""
|
|
||||||
logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...")
|
|
||||||
|
|
||||||
# 1. state 검증 및 사용자 정보 추출
|
|
||||||
state_key = f"{self.STATE_KEY_PREFIX}{state}"
|
|
||||||
state_data_str = await redis_client.get(state_key)
|
|
||||||
|
|
||||||
if state_data_str is None:
|
|
||||||
logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...")
|
|
||||||
raise OAuthStateExpiredError()
|
|
||||||
|
|
||||||
# state 데이터 파싱 (JSON 역직렬화)
|
|
||||||
state_data = json.loads(state_data_str)
|
|
||||||
user_uuid = state_data["user_uuid"]
|
|
||||||
platform = SocialPlatform(state_data["platform"])
|
|
||||||
|
|
||||||
# state 삭제 (일회성)
|
|
||||||
await redis_client.delete(state_key)
|
|
||||||
logger.debug(f"[SOCIAL] state 토큰 사용 완료 및 삭제 - user_uuid: {user_uuid}")
|
|
||||||
|
|
||||||
# 2. OAuth 클라이언트로 토큰 교환
|
|
||||||
oauth_client = get_oauth_client(platform)
|
|
||||||
token_response = await oauth_client.exchange_code(code)
|
|
||||||
|
|
||||||
# 3. 플랫폼 사용자 정보 조회
|
|
||||||
user_info = await oauth_client.get_user_info(token_response.access_token)
|
|
||||||
|
|
||||||
# 4. 기존 연동 확인 (소프트 삭제된 계정 포함)
|
|
||||||
existing_account = await self._get_social_account(
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
platform=platform,
|
|
||||||
platform_user_id=user_info.platform_user_id,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_account:
|
|
||||||
# 기존 계정 존재 (활성화 또는 비활성화 상태)
|
|
||||||
is_reactivation = False
|
|
||||||
if existing_account.is_active and not existing_account.is_deleted:
|
|
||||||
# 이미 활성화된 계정 - 토큰만 갱신
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL] 기존 활성 계정 토큰 갱신 - "
|
|
||||||
f"account_id: {existing_account.id}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 비활성화(소프트 삭제)된 계정 - 재활성화
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL] 비활성 계정 재활성화 - "
|
|
||||||
f"account_id: {existing_account.id}"
|
|
||||||
)
|
|
||||||
existing_account.is_active = True
|
|
||||||
existing_account.is_deleted = False
|
|
||||||
is_reactivation = True
|
|
||||||
|
|
||||||
# 토큰 및 정보 업데이트
|
|
||||||
existing_account = await self._update_tokens(
|
|
||||||
account=existing_account,
|
|
||||||
token_response=token_response,
|
|
||||||
user_info=user_info,
|
|
||||||
session=session,
|
|
||||||
update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트
|
|
||||||
)
|
|
||||||
return self._to_response(existing_account)
|
|
||||||
|
|
||||||
# 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만)
|
|
||||||
social_account = await self._create_social_account(
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
platform=platform,
|
|
||||||
token_response=token_response,
|
|
||||||
user_info=user_info,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL] 소셜 계정 연동 완료 - "
|
|
||||||
f"account_id: {social_account.id}, platform: {platform.value}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._to_response(social_account)
|
|
||||||
|
|
||||||
async def get_connected_accounts(
|
|
||||||
self,
|
|
||||||
user_uuid: str,
|
|
||||||
session: AsyncSession,
|
|
||||||
auto_refresh: bool = True,
|
|
||||||
) -> list[SocialAccountResponse]:
|
|
||||||
"""
|
|
||||||
연동된 소셜 계정 목록 조회 (토큰 자동 갱신 포함)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_uuid: 사용자 UUID
|
|
||||||
session: DB 세션
|
|
||||||
auto_refresh: 토큰 자동 갱신 여부 (기본 True)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[SocialAccountResponse]: 연동된 계정 목록
|
|
||||||
"""
|
|
||||||
logger.info(f"[SOCIAL] 연동 계정 목록 조회 - user_uuid: {user_uuid}")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialAccount).where(
|
|
||||||
SocialAccount.user_uuid == user_uuid,
|
|
||||||
SocialAccount.is_active == True, # noqa: E712
|
|
||||||
SocialAccount.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
accounts = result.scalars().all()
|
|
||||||
|
|
||||||
logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨")
|
|
||||||
|
|
||||||
# 토큰 자동 갱신
|
|
||||||
if auto_refresh:
|
|
||||||
for account in accounts:
|
|
||||||
await self._try_refresh_token(account, session)
|
|
||||||
|
|
||||||
return [self._to_response(account) for account in accounts]
|
|
||||||
|
|
||||||
async def refresh_all_tokens(
|
|
||||||
self,
|
|
||||||
user_uuid: str,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> dict[str, bool]:
|
|
||||||
"""
|
|
||||||
사용자의 모든 연동 계정 토큰 갱신 (로그인 시 호출)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_uuid: 사용자 UUID
|
|
||||||
session: DB 세션
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, bool]: 플랫폼별 갱신 성공 여부
|
|
||||||
"""
|
|
||||||
logger.info(f"[SOCIAL] 모든 연동 계정 토큰 갱신 시작 - user_uuid: {user_uuid}")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialAccount).where(
|
|
||||||
SocialAccount.user_uuid == user_uuid,
|
|
||||||
SocialAccount.is_active == True, # noqa: E712
|
|
||||||
SocialAccount.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
accounts = result.scalars().all()
|
|
||||||
|
|
||||||
refresh_results = {}
|
|
||||||
for account in accounts:
|
|
||||||
success = await self._try_refresh_token(account, session)
|
|
||||||
refresh_results[f"{account.platform}_{account.id}"] = success
|
|
||||||
|
|
||||||
logger.info(f"[SOCIAL] 토큰 갱신 완료 - results: {refresh_results}")
|
|
||||||
return refresh_results
|
|
||||||
|
|
||||||
async def _try_refresh_token(
|
|
||||||
self,
|
|
||||||
account: SocialAccount,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
토큰 갱신 시도 (실패해도 예외 발생하지 않음)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: 소셜 계정
|
|
||||||
session: DB 세션
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 갱신 성공 여부
|
|
||||||
"""
|
|
||||||
# refresh_token이 없으면 갱신 불가
|
|
||||||
if not account.refresh_token:
|
|
||||||
logger.debug(
|
|
||||||
f"[SOCIAL] refresh_token 없음, 갱신 스킵 - account_id: {account.id}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 만료 시간 확인 (만료 1시간 전이면 갱신)
|
|
||||||
should_refresh = False
|
|
||||||
if account.token_expires_at is None:
|
|
||||||
should_refresh = True
|
|
||||||
else:
|
|
||||||
# DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교
|
|
||||||
current_time = now().replace(tzinfo=None)
|
|
||||||
buffer_time = current_time + timedelta(hours=1)
|
|
||||||
if account.token_expires_at <= buffer_time:
|
|
||||||
should_refresh = True
|
|
||||||
|
|
||||||
if not should_refresh:
|
|
||||||
logger.debug(
|
|
||||||
f"[SOCIAL] 토큰 아직 유효, 갱신 스킵 - account_id: {account.id}"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 갱신 시도
|
|
||||||
try:
|
|
||||||
await self._refresh_account_token(account, session)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
f"[SOCIAL] 토큰 갱신 실패 (재연동 필요) - "
|
|
||||||
f"account_id: {account.id}, error: {e}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_account_by_platform(
|
|
||||||
self,
|
|
||||||
user_uuid: str,
|
|
||||||
platform: SocialPlatform,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> Optional[SocialAccount]:
|
|
||||||
"""
|
|
||||||
특정 플랫폼의 연동 계정 조회
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_uuid: 사용자 UUID
|
|
||||||
platform: 플랫폼
|
|
||||||
session: DB 세션
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SocialAccount: 소셜 계정 (없으면 None)
|
|
||||||
"""
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialAccount).where(
|
|
||||||
SocialAccount.user_uuid == user_uuid,
|
|
||||||
SocialAccount.platform == platform.value,
|
|
||||||
SocialAccount.is_active == True, # noqa: E712
|
|
||||||
SocialAccount.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def get_account_by_id(
|
|
||||||
self,
|
|
||||||
user_uuid: str,
|
|
||||||
account_id: int,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> Optional[SocialAccount]:
|
|
||||||
"""
|
|
||||||
account_id로 연동 계정 조회 (소유권 검증 포함)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_uuid: 사용자 UUID
|
|
||||||
account_id: 소셜 계정 ID
|
|
||||||
session: DB 세션
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SocialAccount: 소셜 계정 (없으면 None)
|
|
||||||
"""
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialAccount).where(
|
|
||||||
SocialAccount.id == account_id,
|
|
||||||
SocialAccount.user_uuid == user_uuid,
|
|
||||||
SocialAccount.is_active == True, # noqa: E712
|
|
||||||
SocialAccount.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def disconnect_by_account_id(
|
|
||||||
self,
|
|
||||||
user_uuid: str,
|
|
||||||
account_id: int,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
account_id로 소셜 계정 연동 해제
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_uuid: 사용자 UUID
|
|
||||||
account_id: 소셜 계정 ID
|
|
||||||
session: DB 세션
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 연동 해제된 플랫폼 이름
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
SocialAccountNotFoundError: 연동된 계정이 없는 경우
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL] 소셜 계정 연동 해제 시작 (by account_id) - "
|
|
||||||
f"user_uuid: {user_uuid}, account_id: {account_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. account_id로 계정 조회 (user_uuid 소유권 확인 포함)
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialAccount).where(
|
|
||||||
SocialAccount.id == account_id,
|
|
||||||
SocialAccount.user_uuid == user_uuid,
|
|
||||||
SocialAccount.is_active == True, # noqa: E712
|
|
||||||
SocialAccount.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
account = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if account is None:
|
|
||||||
logger.warning(
|
|
||||||
f"[SOCIAL] 연동된 계정 없음 - "
|
|
||||||
f"user_uuid: {user_uuid}, account_id: {account_id}"
|
|
||||||
)
|
|
||||||
raise SocialAccountNotFoundError()
|
|
||||||
|
|
||||||
# 2. 소프트 삭제
|
|
||||||
platform = account.platform
|
|
||||||
account.is_active = False
|
|
||||||
account.is_deleted = True
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL] 소셜 계정 연동 해제 완료 - "
|
|
||||||
f"account_id: {account.id}, platform: {platform}"
|
|
||||||
)
|
|
||||||
return platform
|
|
||||||
|
|
||||||
async def disconnect(
|
|
||||||
self,
|
|
||||||
user_uuid: str,
|
|
||||||
platform: SocialPlatform,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
소셜 계정 연동 해제 (platform 기준, deprecated)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_uuid: 사용자 UUID
|
|
||||||
platform: 연동 해제할 플랫폼
|
|
||||||
session: DB 세션
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 성공 여부
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
SocialAccountNotFoundError: 연동된 계정이 없는 경우
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL] 소셜 계정 연동 해제 시작 - "
|
|
||||||
f"user_uuid: {user_uuid}, platform: {platform.value}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. 연동된 계정 조회
|
|
||||||
account = await self.get_account_by_platform(user_uuid, platform, session)
|
|
||||||
|
|
||||||
if account is None:
|
|
||||||
logger.warning(
|
|
||||||
f"[SOCIAL] 연동된 계정 없음 - "
|
|
||||||
f"user_uuid: {user_uuid}, platform: {platform.value}"
|
|
||||||
)
|
|
||||||
raise SocialAccountNotFoundError(platform=platform.value)
|
|
||||||
|
|
||||||
# 2. 소프트 삭제 (토큰 폐기하지 않음 - 재연결 시 동의 화면 스킵을 위해)
|
|
||||||
# 참고: 사용자가 완전히 앱 연결을 끊으려면 Google 계정 설정에서 직접 해제해야 함
|
|
||||||
account.is_active = False
|
|
||||||
account.is_deleted = True
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"[SOCIAL] 소셜 계정 연동 해제 완료 - account_id: {account.id}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def ensure_valid_token(
|
|
||||||
self,
|
|
||||||
account: SocialAccount,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
토큰 유효성 확인 및 필요시 갱신
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: 소셜 계정
|
|
||||||
session: DB 세션
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 유효한 access_token
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TokenExpiredError: 토큰 갱신 실패 시 (재연동 필요)
|
|
||||||
"""
|
|
||||||
# 만료 시간 확인
|
|
||||||
is_expired = False
|
|
||||||
if account.token_expires_at is None:
|
|
||||||
is_expired = True
|
|
||||||
else:
|
|
||||||
current_time = now().replace(tzinfo=None)
|
|
||||||
buffer_time = current_time + timedelta(minutes=10)
|
|
||||||
if account.token_expires_at <= buffer_time:
|
|
||||||
is_expired = True
|
|
||||||
|
|
||||||
# 아직 유효하면 그대로 사용
|
|
||||||
if not is_expired:
|
|
||||||
return account.access_token
|
|
||||||
|
|
||||||
# 만료됐는데 refresh_token이 없으면 재연동 필요
|
|
||||||
if not account.refresh_token:
|
|
||||||
logger.warning(
|
|
||||||
f"[SOCIAL] access_token 만료 + refresh_token 없음, 재연동 필요 - "
|
|
||||||
f"account_id: {account.id}"
|
|
||||||
)
|
|
||||||
raise TokenExpiredError(platform=account.platform)
|
|
||||||
|
|
||||||
# refresh_token으로 갱신
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
|
|
||||||
)
|
|
||||||
return await self._refresh_account_token(account, session)
|
|
||||||
|
|
||||||
async def _refresh_account_token(
|
|
||||||
self,
|
|
||||||
account: SocialAccount,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
계정 토큰 갱신
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: 소셜 계정
|
|
||||||
session: DB 세션
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 새 access_token
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TokenExpiredError: 갱신 실패 시 (재연동 필요)
|
|
||||||
"""
|
|
||||||
if not account.refresh_token:
|
|
||||||
logger.warning(
|
|
||||||
f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}"
|
|
||||||
)
|
|
||||||
raise TokenExpiredError(platform=account.platform)
|
|
||||||
|
|
||||||
platform = SocialPlatform(account.platform)
|
|
||||||
oauth_client = get_oauth_client(platform)
|
|
||||||
|
|
||||||
try:
|
|
||||||
token_response = await oauth_client.refresh_token(account.refresh_token)
|
|
||||||
except OAuthTokenRefreshError as e:
|
|
||||||
logger.error(
|
|
||||||
f"[SOCIAL] 토큰 갱신 실패, 재연동 필요 - "
|
|
||||||
f"account_id: {account.id}, error: {e}"
|
|
||||||
)
|
|
||||||
raise TokenExpiredError(platform=account.platform)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"[SOCIAL] 토큰 갱신 중 예외 발생, 재연동 필요 - "
|
|
||||||
f"account_id: {account.id}, error: {e}"
|
|
||||||
)
|
|
||||||
raise TokenExpiredError(platform=account.platform)
|
|
||||||
|
|
||||||
# 토큰 업데이트
|
|
||||||
account.access_token = token_response.access_token
|
|
||||||
if token_response.refresh_token:
|
|
||||||
account.refresh_token = token_response.refresh_token
|
|
||||||
if token_response.expires_in:
|
|
||||||
# DB에 naive datetime으로 저장 (MySQL DateTime은 timezone 미지원)
|
|
||||||
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
|
|
||||||
seconds=token_response.expires_in
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(account)
|
|
||||||
|
|
||||||
logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}")
|
|
||||||
return account.access_token
|
|
||||||
|
|
||||||
async def _get_social_account(
|
|
||||||
self,
|
|
||||||
user_uuid: str,
|
|
||||||
platform: SocialPlatform,
|
|
||||||
platform_user_id: str,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> Optional[SocialAccount]:
|
|
||||||
"""
|
|
||||||
소셜 계정 조회 (platform_user_id 포함)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_uuid: 사용자 UUID
|
|
||||||
platform: 플랫폼
|
|
||||||
platform_user_id: 플랫폼 사용자 ID
|
|
||||||
session: DB 세션
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SocialAccount: 소셜 계정 (없으면 None)
|
|
||||||
"""
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialAccount).where(
|
|
||||||
SocialAccount.user_uuid == user_uuid,
|
|
||||||
SocialAccount.platform == platform.value,
|
|
||||||
SocialAccount.platform_user_id == platform_user_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def _create_social_account(
|
|
||||||
self,
|
|
||||||
user_uuid: str,
|
|
||||||
platform: SocialPlatform,
|
|
||||||
token_response: OAuthTokenResponse,
|
|
||||||
user_info: PlatformUserInfo,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> SocialAccount:
|
|
||||||
"""
|
|
||||||
새 소셜 계정 생성
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_uuid: 사용자 UUID
|
|
||||||
platform: 플랫폼
|
|
||||||
token_response: OAuth 토큰 응답
|
|
||||||
user_info: 플랫폼 사용자 정보
|
|
||||||
session: DB 세션
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SocialAccount: 생성된 소셜 계정
|
|
||||||
"""
|
|
||||||
# 토큰 만료 시간 계산 (DB에 naive datetime으로 저장)
|
|
||||||
token_expires_at = None
|
|
||||||
if token_response.expires_in:
|
|
||||||
token_expires_at = now().replace(tzinfo=None) + timedelta(
|
|
||||||
seconds=token_response.expires_in
|
|
||||||
)
|
|
||||||
|
|
||||||
social_account = SocialAccount(
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
platform=platform.value,
|
|
||||||
access_token=token_response.access_token,
|
|
||||||
refresh_token=token_response.refresh_token,
|
|
||||||
token_expires_at=token_expires_at,
|
|
||||||
scope=token_response.scope,
|
|
||||||
platform_user_id=user_info.platform_user_id,
|
|
||||||
platform_username=user_info.username,
|
|
||||||
platform_data={
|
|
||||||
"display_name": user_info.display_name,
|
|
||||||
"profile_image_url": user_info.profile_image_url,
|
|
||||||
**user_info.platform_data,
|
|
||||||
},
|
|
||||||
is_active=True,
|
|
||||||
is_deleted=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
session.add(social_account)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(social_account)
|
|
||||||
|
|
||||||
return social_account
|
|
||||||
|
|
||||||
async def _update_tokens(
|
|
||||||
self,
|
|
||||||
account: SocialAccount,
|
|
||||||
token_response: OAuthTokenResponse,
|
|
||||||
user_info: PlatformUserInfo,
|
|
||||||
session: AsyncSession,
|
|
||||||
update_connected_at: bool = False,
|
|
||||||
) -> SocialAccount:
|
|
||||||
"""
|
|
||||||
기존 계정 토큰 업데이트
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: 기존 소셜 계정
|
|
||||||
token_response: 새 OAuth 토큰 응답
|
|
||||||
user_info: 플랫폼 사용자 정보
|
|
||||||
session: DB 세션
|
|
||||||
update_connected_at: 연결 시간 업데이트 여부 (재연결 시 True)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SocialAccount: 업데이트된 소셜 계정
|
|
||||||
"""
|
|
||||||
account.access_token = token_response.access_token
|
|
||||||
if token_response.refresh_token:
|
|
||||||
account.refresh_token = token_response.refresh_token
|
|
||||||
if token_response.expires_in:
|
|
||||||
# DB에 naive datetime으로 저장
|
|
||||||
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
|
|
||||||
seconds=token_response.expires_in
|
|
||||||
)
|
|
||||||
if token_response.scope:
|
|
||||||
account.scope = token_response.scope
|
|
||||||
|
|
||||||
# 플랫폼 정보 업데이트
|
|
||||||
account.platform_username = user_info.username
|
|
||||||
account.platform_data = {
|
|
||||||
"display_name": user_info.display_name,
|
|
||||||
"profile_image_url": user_info.profile_image_url,
|
|
||||||
**user_info.platform_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 재연결 시 연결 시간 업데이트
|
|
||||||
if update_connected_at:
|
|
||||||
account.connected_at = now().replace(tzinfo=None)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(account)
|
|
||||||
|
|
||||||
return account
|
|
||||||
|
|
||||||
def _to_response(self, account: SocialAccount) -> SocialAccountResponse:
|
|
||||||
"""
|
|
||||||
SocialAccount를 SocialAccountResponse로 변환
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: 소셜 계정
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SocialAccountResponse: 응답 스키마
|
|
||||||
"""
|
|
||||||
platform_data = account.platform_data or {}
|
|
||||||
|
|
||||||
return SocialAccountResponse(
|
|
||||||
id=account.id,
|
|
||||||
platform=account.platform,
|
|
||||||
platform_user_id=account.platform_user_id,
|
|
||||||
platform_username=account.platform_username,
|
|
||||||
display_name=platform_data.get("display_name"),
|
|
||||||
profile_image_url=platform_data.get("profile_image_url"),
|
|
||||||
is_active=account.is_active,
|
|
||||||
connected_at=account.connected_at,
|
|
||||||
platform_data=platform_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 싱글톤 인스턴스
|
|
||||||
social_account_service = SocialAccountService()
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
"""
|
|
||||||
Social Uploader Module
|
|
||||||
|
|
||||||
소셜 미디어 영상 업로더 모듈입니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.social.constants import SocialPlatform
|
|
||||||
from app.social.uploader.base import BaseSocialUploader, UploadResult
|
|
||||||
|
|
||||||
|
|
||||||
def get_uploader(platform: SocialPlatform) -> BaseSocialUploader:
|
|
||||||
"""
|
|
||||||
플랫폼에 맞는 업로더 반환
|
|
||||||
|
|
||||||
Args:
|
|
||||||
platform: 소셜 플랫폼
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BaseSocialUploader: 업로더 인스턴스
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 지원하지 않는 플랫폼인 경우
|
|
||||||
"""
|
|
||||||
if platform == SocialPlatform.YOUTUBE:
|
|
||||||
from app.social.uploader.youtube import YouTubeUploader
|
|
||||||
|
|
||||||
return YouTubeUploader()
|
|
||||||
|
|
||||||
# 추후 확장
|
|
||||||
# elif platform == SocialPlatform.INSTAGRAM:
|
|
||||||
# from app.social.uploader.instagram import InstagramUploader
|
|
||||||
# return InstagramUploader()
|
|
||||||
# elif platform == SocialPlatform.FACEBOOK:
|
|
||||||
# from app.social.uploader.facebook import FacebookUploader
|
|
||||||
# return FacebookUploader()
|
|
||||||
# elif platform == SocialPlatform.TIKTOK:
|
|
||||||
# from app.social.uploader.tiktok import TikTokUploader
|
|
||||||
# return TikTokUploader()
|
|
||||||
|
|
||||||
raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}")
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"BaseSocialUploader",
|
|
||||||
"UploadResult",
|
|
||||||
"get_uploader",
|
|
||||||
]
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
"""
|
|
||||||
Base Social Uploader
|
|
||||||
|
|
||||||
소셜 미디어 영상 업로더의 추상 기본 클래스입니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Callable, Optional
|
|
||||||
|
|
||||||
from app.social.constants import PrivacyStatus, SocialPlatform
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UploadMetadata:
|
|
||||||
"""
|
|
||||||
업로드 메타데이터
|
|
||||||
|
|
||||||
영상 업로드 시 필요한 메타데이터를 정의합니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
title: 영상 제목
|
|
||||||
description: 영상 설명
|
|
||||||
tags: 태그 목록
|
|
||||||
privacy_status: 공개 상태
|
|
||||||
platform_options: 플랫폼별 추가 옵션
|
|
||||||
"""
|
|
||||||
|
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
tags: Optional[list[str]] = None
|
|
||||||
privacy_status: PrivacyStatus = PrivacyStatus.PRIVATE
|
|
||||||
platform_options: Optional[dict[str, Any]] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UploadResult:
|
|
||||||
"""
|
|
||||||
업로드 결과
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
success: 성공 여부
|
|
||||||
platform_video_id: 플랫폼에서 부여한 영상 ID
|
|
||||||
platform_url: 플랫폼에서의 영상 URL
|
|
||||||
error_message: 에러 메시지 (실패 시)
|
|
||||||
platform_response: 플랫폼 원본 응답 (디버깅용)
|
|
||||||
"""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
platform_video_id: Optional[str] = None
|
|
||||||
platform_url: Optional[str] = None
|
|
||||||
error_message: Optional[str] = None
|
|
||||||
platform_response: Optional[dict[str, Any]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BaseSocialUploader(ABC):
|
|
||||||
"""
|
|
||||||
소셜 미디어 영상 업로더 추상 기본 클래스
|
|
||||||
|
|
||||||
모든 플랫폼별 업로더는 이 클래스를 상속받아 구현합니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
platform: 소셜 플랫폼 종류
|
|
||||||
"""
|
|
||||||
|
|
||||||
platform: SocialPlatform
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def upload(
|
|
||||||
self,
|
|
||||||
video_path: str,
|
|
||||||
access_token: str,
|
|
||||||
metadata: UploadMetadata,
|
|
||||||
progress_callback: Optional[Callable[[int], None]] = None,
|
|
||||||
) -> UploadResult:
|
|
||||||
"""
|
|
||||||
영상 업로드
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_path: 업로드할 영상 파일 경로 (로컬 또는 URL)
|
|
||||||
access_token: OAuth 액세스 토큰
|
|
||||||
metadata: 업로드 메타데이터
|
|
||||||
progress_callback: 진행률 콜백 함수 (0-100)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
UploadResult: 업로드 결과
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_upload_status(
|
|
||||||
self,
|
|
||||||
platform_video_id: str,
|
|
||||||
access_token: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
업로드 상태 조회
|
|
||||||
|
|
||||||
플랫폼에서 영상 처리 상태를 조회합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
platform_video_id: 플랫폼 영상 ID
|
|
||||||
access_token: OAuth 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: 업로드 상태 정보
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def delete_video(
|
|
||||||
self,
|
|
||||||
platform_video_id: str,
|
|
||||||
access_token: str,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
업로드된 영상 삭제
|
|
||||||
|
|
||||||
Args:
|
|
||||||
platform_video_id: 플랫폼 영상 ID
|
|
||||||
access_token: OAuth 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 삭제 성공 여부
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def validate_metadata(self, metadata: UploadMetadata) -> None:
|
|
||||||
"""
|
|
||||||
메타데이터 유효성 검증
|
|
||||||
|
|
||||||
플랫폼별 제한사항을 확인합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
metadata: 검증할 메타데이터
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 유효하지 않은 메타데이터
|
|
||||||
"""
|
|
||||||
if not metadata.title or len(metadata.title) == 0:
|
|
||||||
raise ValueError("제목은 필수입니다.")
|
|
||||||
|
|
||||||
if len(metadata.title) > 100:
|
|
||||||
raise ValueError("제목은 100자를 초과할 수 없습니다.")
|
|
||||||
|
|
||||||
if metadata.description and len(metadata.description) > 5000:
|
|
||||||
raise ValueError("설명은 5000자를 초과할 수 없습니다.")
|
|
||||||
|
|
||||||
def get_video_url(self, platform_video_id: str) -> str:
|
|
||||||
"""
|
|
||||||
플랫폼 영상 URL 생성
|
|
||||||
|
|
||||||
Args:
|
|
||||||
platform_video_id: 플랫폼 영상 ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 영상 URL
|
|
||||||
"""
|
|
||||||
if self.platform == SocialPlatform.YOUTUBE:
|
|
||||||
return f"https://www.youtube.com/watch?v={platform_video_id}"
|
|
||||||
elif self.platform == SocialPlatform.INSTAGRAM:
|
|
||||||
return f"https://www.instagram.com/reel/{platform_video_id}/"
|
|
||||||
elif self.platform == SocialPlatform.FACEBOOK:
|
|
||||||
return f"https://www.facebook.com/watch/?v={platform_video_id}"
|
|
||||||
elif self.platform == SocialPlatform.TIKTOK:
|
|
||||||
return f"https://www.tiktok.com/video/{platform_video_id}"
|
|
||||||
else:
|
|
||||||
return ""
|
|
||||||
|
|
@ -1,420 +0,0 @@
|
||||||
"""
|
|
||||||
YouTube Uploader
|
|
||||||
|
|
||||||
YouTube Data API v3를 사용한 영상 업로더입니다.
|
|
||||||
Resumable Upload를 지원합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from typing import Any, Callable, Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from config import social_upload_settings
|
|
||||||
from app.social.constants import PrivacyStatus, SocialPlatform
|
|
||||||
from app.social.exceptions import UploadError, UploadQuotaExceededError
|
|
||||||
from app.social.uploader.base import BaseSocialUploader, UploadMetadata, UploadResult
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeUploader(BaseSocialUploader):
|
|
||||||
"""
|
|
||||||
YouTube 영상 업로더
|
|
||||||
|
|
||||||
YouTube Data API v3의 Resumable Upload를 사용하여
|
|
||||||
대용량 영상을 안정적으로 업로드합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
platform = SocialPlatform.YOUTUBE
|
|
||||||
|
|
||||||
# YouTube API 엔드포인트
|
|
||||||
UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos"
|
|
||||||
VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"
|
|
||||||
|
|
||||||
# 청크 크기 (5MB - YouTube 권장)
|
|
||||||
CHUNK_SIZE = 5 * 1024 * 1024
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.timeout = social_upload_settings.UPLOAD_TIMEOUT_SECONDS
|
|
||||||
|
|
||||||
async def upload(
|
|
||||||
self,
|
|
||||||
video_path: str,
|
|
||||||
access_token: str,
|
|
||||||
metadata: UploadMetadata,
|
|
||||||
progress_callback: Optional[Callable[[int], None]] = None,
|
|
||||||
) -> UploadResult:
|
|
||||||
"""
|
|
||||||
YouTube에 영상 업로드 (Resumable Upload)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_path: 업로드할 영상 파일 경로
|
|
||||||
access_token: OAuth 액세스 토큰
|
|
||||||
metadata: 업로드 메타데이터
|
|
||||||
progress_callback: 진행률 콜백 함수 (0-100)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
UploadResult: 업로드 결과
|
|
||||||
"""
|
|
||||||
logger.info(f"[YOUTUBE_UPLOAD] 업로드 시작 - video_path: {video_path}")
|
|
||||||
|
|
||||||
# 1. 메타데이터 유효성 검증
|
|
||||||
self.validate_metadata(metadata)
|
|
||||||
|
|
||||||
# 2. 파일 크기 확인
|
|
||||||
if not os.path.exists(video_path):
|
|
||||||
logger.error(f"[YOUTUBE_UPLOAD] 파일 없음 - path: {video_path}")
|
|
||||||
return UploadResult(
|
|
||||||
success=False,
|
|
||||||
error_message=f"파일을 찾을 수 없습니다: {video_path}",
|
|
||||||
)
|
|
||||||
|
|
||||||
file_size = os.path.getsize(video_path)
|
|
||||||
logger.info(f"[YOUTUBE_UPLOAD] 파일 크기: {file_size / (1024*1024):.2f} MB")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 3. Resumable upload 세션 시작
|
|
||||||
upload_url = await self._init_resumable_upload(
|
|
||||||
access_token=access_token,
|
|
||||||
metadata=metadata,
|
|
||||||
file_size=file_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 파일 업로드
|
|
||||||
video_id = await self._upload_file(
|
|
||||||
upload_url=upload_url,
|
|
||||||
video_path=video_path,
|
|
||||||
file_size=file_size,
|
|
||||||
progress_callback=progress_callback,
|
|
||||||
)
|
|
||||||
|
|
||||||
video_url = self.get_video_url(video_id)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[YOUTUBE_UPLOAD] 업로드 성공 - video_id: {video_id}, url: {video_url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return UploadResult(
|
|
||||||
success=True,
|
|
||||||
platform_video_id=video_id,
|
|
||||||
platform_url=video_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
except UploadQuotaExceededError:
|
|
||||||
raise
|
|
||||||
except UploadError as e:
|
|
||||||
logger.error(f"[YOUTUBE_UPLOAD] 업로드 실패 - error: {e}")
|
|
||||||
return UploadResult(
|
|
||||||
success=False,
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[YOUTUBE_UPLOAD] 예상치 못한 에러 - error: {e}")
|
|
||||||
return UploadResult(
|
|
||||||
success=False,
|
|
||||||
error_message=f"업로드 중 에러 발생: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _init_resumable_upload(
|
|
||||||
self,
|
|
||||||
access_token: str,
|
|
||||||
metadata: UploadMetadata,
|
|
||||||
file_size: int,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Resumable upload 세션 시작
|
|
||||||
|
|
||||||
Args:
|
|
||||||
access_token: OAuth 액세스 토큰
|
|
||||||
metadata: 업로드 메타데이터
|
|
||||||
file_size: 파일 크기
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 업로드 URL
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
UploadError: 세션 시작 실패
|
|
||||||
"""
|
|
||||||
logger.debug("[YOUTUBE_UPLOAD] Resumable upload 세션 시작")
|
|
||||||
|
|
||||||
# YouTube API 요청 본문
|
|
||||||
body = {
|
|
||||||
"snippet": {
|
|
||||||
"title": metadata.title,
|
|
||||||
"description": metadata.description or "",
|
|
||||||
"tags": metadata.tags or [],
|
|
||||||
"categoryId": self._get_category_id(metadata),
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"privacyStatus": self._convert_privacy_status(metadata.privacy_status),
|
|
||||||
"selfDeclaredMadeForKids": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {access_token}",
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
"X-Upload-Content-Type": "video/*",
|
|
||||||
"X-Upload-Content-Length": str(file_size),
|
|
||||||
}
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"uploadType": "resumable",
|
|
||||||
"part": "snippet,status",
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
response = await client.post(
|
|
||||||
self.UPLOAD_URL,
|
|
||||||
params=params,
|
|
||||||
headers=headers,
|
|
||||||
json=body,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
upload_url = response.headers.get("location")
|
|
||||||
if upload_url:
|
|
||||||
logger.debug(
|
|
||||||
f"[YOUTUBE_UPLOAD] 세션 시작 성공 - upload_url: {upload_url[:50]}..."
|
|
||||||
)
|
|
||||||
return upload_url
|
|
||||||
|
|
||||||
# 에러 처리
|
|
||||||
error_data = response.json() if response.content else {}
|
|
||||||
error_reason = (
|
|
||||||
error_data.get("error", {}).get("errors", [{}])[0].get("reason", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
if error_reason == "quotaExceeded":
|
|
||||||
logger.error("[YOUTUBE_UPLOAD] API 할당량 초과")
|
|
||||||
raise UploadQuotaExceededError(platform=self.platform.value)
|
|
||||||
|
|
||||||
error_message = error_data.get("error", {}).get(
|
|
||||||
"message", f"HTTP {response.status_code}"
|
|
||||||
)
|
|
||||||
logger.error(f"[YOUTUBE_UPLOAD] 세션 시작 실패 - error: {error_message}")
|
|
||||||
raise UploadError(
|
|
||||||
platform=self.platform.value,
|
|
||||||
detail=f"Resumable upload 세션 시작 실패: {error_message}",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _upload_file(
|
|
||||||
self,
|
|
||||||
upload_url: str,
|
|
||||||
video_path: str,
|
|
||||||
file_size: int,
|
|
||||||
progress_callback: Optional[Callable[[int], None]] = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
파일 청크 업로드
|
|
||||||
|
|
||||||
Args:
|
|
||||||
upload_url: Resumable upload URL
|
|
||||||
video_path: 영상 파일 경로
|
|
||||||
file_size: 파일 크기
|
|
||||||
progress_callback: 진행률 콜백
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: YouTube 영상 ID
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
UploadError: 업로드 실패
|
|
||||||
"""
|
|
||||||
uploaded_bytes = 0
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
||||||
with open(video_path, "rb") as video_file:
|
|
||||||
while uploaded_bytes < file_size:
|
|
||||||
# 청크 읽기
|
|
||||||
chunk = video_file.read(self.CHUNK_SIZE)
|
|
||||||
chunk_size = len(chunk)
|
|
||||||
end_byte = uploaded_bytes + chunk_size - 1
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "video/*",
|
|
||||||
"Content-Length": str(chunk_size),
|
|
||||||
"Content-Range": f"bytes {uploaded_bytes}-{end_byte}/{file_size}",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await client.put(
|
|
||||||
upload_url,
|
|
||||||
headers=headers,
|
|
||||||
content=chunk,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200 or response.status_code == 201:
|
|
||||||
# 업로드 완료
|
|
||||||
result = response.json()
|
|
||||||
video_id = result.get("id")
|
|
||||||
if video_id:
|
|
||||||
return video_id
|
|
||||||
raise UploadError(
|
|
||||||
platform=self.platform.value,
|
|
||||||
detail="응답에서 video ID를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
elif response.status_code == 308:
|
|
||||||
# 청크 업로드 성공, 계속 진행
|
|
||||||
uploaded_bytes += chunk_size
|
|
||||||
progress = int((uploaded_bytes / file_size) * 100)
|
|
||||||
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(progress)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"[YOUTUBE_UPLOAD] 청크 업로드 완료 - "
|
|
||||||
f"progress: {progress}%, "
|
|
||||||
f"uploaded: {uploaded_bytes}/{file_size}"
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# 에러
|
|
||||||
error_data = response.json() if response.content else {}
|
|
||||||
error_message = error_data.get("error", {}).get(
|
|
||||||
"message", f"HTTP {response.status_code}"
|
|
||||||
)
|
|
||||||
logger.error(
|
|
||||||
f"[YOUTUBE_UPLOAD] 청크 업로드 실패 - error: {error_message}"
|
|
||||||
)
|
|
||||||
raise UploadError(
|
|
||||||
platform=self.platform.value,
|
|
||||||
detail=f"청크 업로드 실패: {error_message}",
|
|
||||||
)
|
|
||||||
|
|
||||||
raise UploadError(
|
|
||||||
platform=self.platform.value,
|
|
||||||
detail="업로드가 완료되지 않았습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_upload_status(
|
|
||||||
self,
|
|
||||||
platform_video_id: str,
|
|
||||||
access_token: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
업로드 상태 조회
|
|
||||||
|
|
||||||
Args:
|
|
||||||
platform_video_id: YouTube 영상 ID
|
|
||||||
access_token: OAuth 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: 업로드 상태 정보
|
|
||||||
"""
|
|
||||||
logger.info(f"[YOUTUBE_UPLOAD] 상태 조회 - video_id: {platform_video_id}")
|
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
|
||||||
params = {
|
|
||||||
"part": "status,processingDetails",
|
|
||||||
"id": platform_video_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
response = await client.get(
|
|
||||||
self.VIDEOS_URL,
|
|
||||||
headers=headers,
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
items = data.get("items", [])
|
|
||||||
|
|
||||||
if items:
|
|
||||||
item = items[0]
|
|
||||||
status = item.get("status", {})
|
|
||||||
processing = item.get("processingDetails", {})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"upload_status": status.get("uploadStatus"),
|
|
||||||
"privacy_status": status.get("privacyStatus"),
|
|
||||||
"processing_status": processing.get(
|
|
||||||
"processingStatus", "processing"
|
|
||||||
),
|
|
||||||
"processing_progress": processing.get(
|
|
||||||
"processingProgress", {}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"error": "영상을 찾을 수 없습니다."}
|
|
||||||
|
|
||||||
return {"error": f"상태 조회 실패: HTTP {response.status_code}"}
|
|
||||||
|
|
||||||
async def delete_video(
|
|
||||||
self,
|
|
||||||
platform_video_id: str,
|
|
||||||
access_token: str,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
업로드된 영상 삭제
|
|
||||||
|
|
||||||
Args:
|
|
||||||
platform_video_id: YouTube 영상 ID
|
|
||||||
access_token: OAuth 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 삭제 성공 여부
|
|
||||||
"""
|
|
||||||
logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 - video_id: {platform_video_id}")
|
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
|
||||||
params = {"id": platform_video_id}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
response = await client.delete(
|
|
||||||
self.VIDEOS_URL,
|
|
||||||
headers=headers,
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 204:
|
|
||||||
logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 성공 - video_id: {platform_video_id}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"[YOUTUBE_UPLOAD] 영상 삭제 실패 - "
|
|
||||||
f"video_id: {platform_video_id}, status: {response.status_code}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
|
|
||||||
"""
|
|
||||||
PrivacyStatus를 YouTube API 형식으로 변환
|
|
||||||
|
|
||||||
Args:
|
|
||||||
privacy_status: 공개 상태
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: YouTube API 공개 상태
|
|
||||||
"""
|
|
||||||
mapping = {
|
|
||||||
PrivacyStatus.PUBLIC: "public",
|
|
||||||
PrivacyStatus.UNLISTED: "unlisted",
|
|
||||||
PrivacyStatus.PRIVATE: "private",
|
|
||||||
}
|
|
||||||
return mapping.get(privacy_status, "private")
|
|
||||||
|
|
||||||
def _get_category_id(self, metadata: UploadMetadata) -> str:
|
|
||||||
"""
|
|
||||||
카테고리 ID 추출
|
|
||||||
|
|
||||||
platform_options에서 category_id를 추출하거나 기본값 반환
|
|
||||||
|
|
||||||
Args:
|
|
||||||
metadata: 업로드 메타데이터
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: YouTube 카테고리 ID
|
|
||||||
"""
|
|
||||||
if metadata.platform_options and "category_id" in metadata.platform_options:
|
|
||||||
return str(metadata.platform_options["category_id"])
|
|
||||||
|
|
||||||
# 기본값: "22" (People & Blogs)
|
|
||||||
return "22"
|
|
||||||
|
|
||||||
|
|
||||||
# 싱글톤 인스턴스
|
|
||||||
youtube_uploader = YouTubeUploader()
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
"""
|
|
||||||
Social Worker Module
|
|
||||||
|
|
||||||
소셜 미디어 백그라운드 태스크 모듈입니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.social.worker.upload_task import process_social_upload
|
|
||||||
|
|
||||||
__all__ = ["process_social_upload"]
|
|
||||||
|
|
@ -1,386 +0,0 @@
|
||||||
"""
|
|
||||||
Social Upload Background Task
|
|
||||||
|
|
||||||
소셜 미디어 영상 업로드 백그라운드 태스크입니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
|
|
||||||
from app.utils.timezone import now
|
|
||||||
import httpx
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
|
|
||||||
from config import social_upload_settings
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
|
||||||
from app.social.constants import SocialPlatform, UploadStatus
|
|
||||||
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError
|
|
||||||
from app.social.models import SocialUpload
|
|
||||||
from app.social.services import social_account_service
|
|
||||||
from app.social.uploader import get_uploader
|
|
||||||
from app.social.uploader.base import UploadMetadata
|
|
||||||
from app.user.models import SocialAccount
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def _update_upload_status(
|
|
||||||
upload_id: int,
|
|
||||||
status: UploadStatus,
|
|
||||||
upload_progress: int = 0,
|
|
||||||
platform_video_id: Optional[str] = None,
|
|
||||||
platform_url: Optional[str] = None,
|
|
||||||
error_message: Optional[str] = None,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
업로드 상태 업데이트
|
|
||||||
|
|
||||||
Args:
|
|
||||||
upload_id: SocialUpload ID
|
|
||||||
status: 업로드 상태
|
|
||||||
upload_progress: 업로드 진행률 (0-100)
|
|
||||||
platform_video_id: 플랫폼 영상 ID
|
|
||||||
platform_url: 플랫폼 영상 URL
|
|
||||||
error_message: 에러 메시지
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 업데이트 성공 여부
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(SocialUpload.id == upload_id)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if upload:
|
|
||||||
upload.status = status.value
|
|
||||||
upload.upload_progress = upload_progress
|
|
||||||
|
|
||||||
if platform_video_id:
|
|
||||||
upload.platform_video_id = platform_video_id
|
|
||||||
if platform_url:
|
|
||||||
upload.platform_url = platform_url
|
|
||||||
if error_message:
|
|
||||||
upload.error_message = error_message
|
|
||||||
if status == UploadStatus.COMPLETED:
|
|
||||||
upload.uploaded_at = now().replace(tzinfo=None)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL_UPLOAD] 상태 업데이트 - "
|
|
||||||
f"upload_id: {upload_id}, status: {status.value}, progress: {upload_progress}%"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"[SOCIAL_UPLOAD] DB 에러 - upload_id: {upload_id}, error: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _download_video(video_url: str, upload_id: int) -> bytes:
|
|
||||||
"""
|
|
||||||
영상 파일 다운로드
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_url: 영상 URL
|
|
||||||
upload_id: 업로드 ID (로그용)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bytes: 영상 파일 내용
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
httpx.HTTPError: 다운로드 실패
|
|
||||||
"""
|
|
||||||
logger.info(f"[SOCIAL_UPLOAD] 영상 다운로드 시작 - upload_id: {upload_id}")
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
|
||||||
response = await client.get(video_url)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL_UPLOAD] 영상 다운로드 완료 - "
|
|
||||||
f"upload_id: {upload_id}, size: {len(response.content)} bytes"
|
|
||||||
)
|
|
||||||
return response.content
|
|
||||||
|
|
||||||
|
|
||||||
async def _increment_retry_count(upload_id: int) -> int:
|
|
||||||
"""
|
|
||||||
재시도 횟수 증가
|
|
||||||
|
|
||||||
Args:
|
|
||||||
upload_id: SocialUpload ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: 현재 재시도 횟수
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(SocialUpload.id == upload_id)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if upload:
|
|
||||||
upload.retry_count += 1
|
|
||||||
await session.commit()
|
|
||||||
return upload.retry_count
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
except SQLAlchemyError:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
async def process_social_upload(upload_id: int) -> None:
|
|
||||||
"""
|
|
||||||
소셜 미디어 업로드 처리
|
|
||||||
|
|
||||||
백그라운드에서 실행되며, 영상을 소셜 플랫폼에 업로드합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
upload_id: SocialUpload ID
|
|
||||||
"""
|
|
||||||
logger.info(f"[SOCIAL_UPLOAD] 업로드 태스크 시작 - upload_id: {upload_id}")
|
|
||||||
|
|
||||||
temp_file_path: Optional[Path] = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. 업로드 정보 조회
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(SocialUpload.id == upload_id)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not upload:
|
|
||||||
logger.error(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. Video 정보 조회
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video).where(Video.id == upload.video_id)
|
|
||||||
)
|
|
||||||
video = video_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not video or not video.result_movie_url:
|
|
||||||
logger.error(
|
|
||||||
f"[SOCIAL_UPLOAD] 영상 없음 또는 URL 없음 - "
|
|
||||||
f"upload_id: {upload_id}, video_id: {upload.video_id}"
|
|
||||||
)
|
|
||||||
await _update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.FAILED,
|
|
||||||
error_message="영상을 찾을 수 없거나 URL이 없습니다.",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# 3. SocialAccount 정보 조회
|
|
||||||
account_result = await session.execute(
|
|
||||||
select(SocialAccount).where(SocialAccount.id == upload.social_account_id)
|
|
||||||
)
|
|
||||||
account = account_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not account or not account.is_active:
|
|
||||||
logger.error(
|
|
||||||
f"[SOCIAL_UPLOAD] 소셜 계정 없음 또는 비활성화 - "
|
|
||||||
f"upload_id: {upload_id}, account_id: {upload.social_account_id}"
|
|
||||||
)
|
|
||||||
await _update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.FAILED,
|
|
||||||
error_message="연동된 소셜 계정이 없거나 비활성화 상태입니다.",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# 필요한 정보 저장
|
|
||||||
video_url = video.result_movie_url
|
|
||||||
platform = SocialPlatform(upload.platform)
|
|
||||||
upload_title = upload.title
|
|
||||||
upload_description = upload.description
|
|
||||||
upload_tags = upload.tags if isinstance(upload.tags, list) else None
|
|
||||||
upload_privacy = upload.privacy_status
|
|
||||||
upload_options = upload.platform_options
|
|
||||||
|
|
||||||
# 4. 상태 업데이트: uploading
|
|
||||||
await _update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.UPLOADING,
|
|
||||||
upload_progress=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. 토큰 유효성 확인 및 갱신
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
# account 다시 조회 (세션이 닫혔으므로)
|
|
||||||
account_result = await session.execute(
|
|
||||||
select(SocialAccount).where(SocialAccount.id == upload.social_account_id)
|
|
||||||
)
|
|
||||||
account = account_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not account:
|
|
||||||
await _update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.FAILED,
|
|
||||||
error_message="소셜 계정을 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
access_token = await social_account_service.ensure_valid_token(
|
|
||||||
account=account,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 6. 영상 다운로드
|
|
||||||
video_content = await _download_video(video_url, upload_id)
|
|
||||||
|
|
||||||
# 7. 임시 파일 저장
|
|
||||||
temp_dir = Path(social_upload_settings.UPLOAD_TEMP_DIR) / str(upload_id)
|
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
temp_file_path = temp_dir / "video.mp4"
|
|
||||||
|
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
|
||||||
await f.write(video_content)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL_UPLOAD] 임시 파일 저장 완료 - "
|
|
||||||
f"upload_id: {upload_id}, path: {temp_file_path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 8. 메타데이터 준비
|
|
||||||
from app.social.constants import PrivacyStatus
|
|
||||||
|
|
||||||
metadata = UploadMetadata(
|
|
||||||
title=upload_title,
|
|
||||||
description=upload_description,
|
|
||||||
tags=upload_tags,
|
|
||||||
privacy_status=PrivacyStatus(upload_privacy),
|
|
||||||
platform_options=upload_options,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 9. 진행률 콜백 함수
|
|
||||||
async def progress_callback(progress: int) -> None:
|
|
||||||
await _update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.UPLOADING,
|
|
||||||
upload_progress=progress,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 10. 플랫폼에 업로드
|
|
||||||
uploader = get_uploader(platform)
|
|
||||||
|
|
||||||
# 동기 콜백으로 변환 (httpx 청크 업로드 내에서 호출되므로)
|
|
||||||
def sync_progress_callback(progress: int) -> None:
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
if loop.is_running():
|
|
||||||
asyncio.create_task(
|
|
||||||
_update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.UPLOADING,
|
|
||||||
upload_progress=progress,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
result = await uploader.upload(
|
|
||||||
video_path=str(temp_file_path),
|
|
||||||
access_token=access_token,
|
|
||||||
metadata=metadata,
|
|
||||||
progress_callback=sync_progress_callback,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 11. 결과 처리
|
|
||||||
if result.success:
|
|
||||||
await _update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.COMPLETED,
|
|
||||||
upload_progress=100,
|
|
||||||
platform_video_id=result.platform_video_id,
|
|
||||||
platform_url=result.platform_url,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"[SOCIAL_UPLOAD] 업로드 완료 - "
|
|
||||||
f"upload_id: {upload_id}, "
|
|
||||||
f"platform_video_id: {result.platform_video_id}, "
|
|
||||||
f"url: {result.platform_url}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
retry_count = await _increment_retry_count(upload_id)
|
|
||||||
|
|
||||||
if retry_count < social_upload_settings.UPLOAD_MAX_RETRIES:
|
|
||||||
# 재시도 가능
|
|
||||||
logger.warning(
|
|
||||||
f"[SOCIAL_UPLOAD] 업로드 실패, 재시도 예정 - "
|
|
||||||
f"upload_id: {upload_id}, retry: {retry_count}"
|
|
||||||
)
|
|
||||||
await _update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.PENDING,
|
|
||||||
upload_progress=0,
|
|
||||||
error_message=f"업로드 실패 (재시도 {retry_count}/{social_upload_settings.UPLOAD_MAX_RETRIES}): {result.error_message}",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 최대 재시도 초과
|
|
||||||
await _update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.FAILED,
|
|
||||||
error_message=f"최대 재시도 횟수 초과: {result.error_message}",
|
|
||||||
)
|
|
||||||
logger.error(
|
|
||||||
f"[SOCIAL_UPLOAD] 업로드 최종 실패 - "
|
|
||||||
f"upload_id: {upload_id}, error: {result.error_message}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except UploadQuotaExceededError as e:
|
|
||||||
logger.error(f"[SOCIAL_UPLOAD] API 할당량 초과 - upload_id: {upload_id}")
|
|
||||||
await _update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.FAILED,
|
|
||||||
error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.",
|
|
||||||
)
|
|
||||||
|
|
||||||
except TokenExpiredError as e:
|
|
||||||
logger.error(
|
|
||||||
f"[SOCIAL_UPLOAD] 토큰 만료, 재연동 필요 - "
|
|
||||||
f"upload_id: {upload_id}, platform: {e.platform}"
|
|
||||||
)
|
|
||||||
await _update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.FAILED,
|
|
||||||
error_message=f"{e.platform} 계정 인증이 만료되었습니다. 계정을 다시 연동해주세요.",
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"[SOCIAL_UPLOAD] 예상치 못한 에러 - "
|
|
||||||
f"upload_id: {upload_id}, error: {e}"
|
|
||||||
)
|
|
||||||
await _update_upload_status(
|
|
||||||
upload_id=upload_id,
|
|
||||||
status=UploadStatus.FAILED,
|
|
||||||
error_message=f"업로드 중 에러 발생: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# 임시 파일 정리
|
|
||||||
if temp_file_path and temp_file_path.exists():
|
|
||||||
try:
|
|
||||||
temp_file_path.unlink()
|
|
||||||
temp_file_path.parent.rmdir()
|
|
||||||
logger.debug(f"[SOCIAL_UPLOAD] 임시 파일 삭제 - path: {temp_file_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[SOCIAL_UPLOAD] 임시 파일 삭제 실패 - error: {e}")
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
"""
|
|
||||||
Song API v1 라우터 모듈
|
|
||||||
"""
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue