Compare commits
142 Commits
v0.3.0-dra
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
219e7ed7c0 | |
|
|
a876825f82 | |
|
|
a4db70c2e6 | |
|
|
172586e699 | |
|
|
b3354d4ad1 | |
|
|
1398546dac | |
|
|
9d074632bc | |
|
|
7f0ae81351 | |
|
|
c89e510c98 | |
|
|
ab8d362aa0 | |
|
|
157d1b1ad9 | |
|
|
f1dd675ecb | |
|
|
ada5dfeeb4 | |
|
|
18635d7995 | |
|
|
54e66e4682 | |
|
|
bc2342163f | |
|
|
34e0cada48 | |
|
|
4e87c76b35 | |
|
|
bc777ba66c | |
|
|
e29e10eb29 | |
|
|
40afe9392c | |
|
|
325fb9af69 | |
|
|
e19c8c9d62 | |
|
|
369e572b0a | |
|
|
f6ce81e14e | |
|
|
32c6c210a0 | |
|
|
c207b8a48f | |
|
|
0d34aa7f99 | |
|
|
dd16013816 | |
|
|
9d92b5d42c | |
|
|
f24ff46b09 | |
|
|
89ea0c783e | |
|
|
c568f949c7 | |
|
|
96597dd555 | |
|
|
5a77d22c9f | |
|
|
f208e93420 | |
|
|
08a699648d | |
|
|
29dd08081b | |
|
|
2cb9d67a70 | |
|
|
e89709ce87 | |
|
|
ca7c0858e2 | |
|
|
e1386b891e | |
|
|
8c7893d989 | |
|
|
eff711e03e | |
|
|
f97ecb29e9 | |
|
|
08d47a6990 | |
|
|
5700965fae | |
|
|
ef203dc14d | |
|
|
19bd12d581 | |
|
|
18b18e9ff2 | |
|
|
f73be9c6d0 | |
|
|
c92d6e2135 | |
|
|
7a0d5a6272 | |
|
|
b8ae598460 | |
|
|
d40e2bd430 | |
|
|
f153157227 | |
|
|
abd098e4c0 | |
|
|
f4821bf157 | |
|
|
2e27eb5742 | |
|
|
1cb698e8ea | |
|
|
51c4ea7552 | |
|
|
fd4d85cf9e | |
|
|
d259740d97 | |
|
|
db853e6604 | |
|
|
e32e795c73 | |
|
|
247a9f3322 | |
|
|
c07a2f6dae | |
|
|
df3bfda594 | |
|
|
7f5a75e0a5 | |
|
|
32ae5530b6 | |
|
|
aa8d9d7c14 | |
|
|
dc7351d0f9 | |
|
|
e30e7304df | |
|
|
1665d11d66 | |
|
|
c7b77fb532 | |
|
|
fea30e79fd | |
|
|
0e1eae75dd | |
|
|
2e9a43263f | |
|
|
3039a65ee4 | |
|
|
f29ac29649 | |
|
|
fc88eedfa2 | |
|
|
72dcd09771 | |
|
|
6d2961cee2 | |
|
|
b48d218a1d | |
|
|
7e2646337f | |
|
|
2ac4b75d96 | |
|
|
1e16e0e3eb | |
|
|
88a91aa6d7 | |
|
|
f6da65044a | |
|
|
4a06bfdde4 | |
|
|
7038faaf74 | |
|
|
219d8798ad | |
|
|
cea23efac3 | |
|
|
a6daff4e38 | |
|
|
8ae2a68ae4 | |
|
|
72e06ee951 | |
|
|
bcd2c0a96f | |
|
|
198513f237 | |
|
|
36de908431 | |
|
|
b4e5d04dbb | |
|
|
b6e50be9ca | |
|
|
47aca58b02 | |
|
|
94be8a0746 | |
|
|
da59f3d6e3 | |
|
|
ee6069e5d5 | |
|
|
ece201f92b | |
|
|
56069a04a1 | |
|
|
f362effe7a | |
|
|
38323870ec | |
|
|
1562aee998 | |
|
|
2f384fb72a | |
|
|
4c47d6e0fc | |
|
|
4e15e44cbe | |
|
|
a9d0a3ee7f | |
|
|
a3d3c75463 | |
|
|
f5130b73d7 | |
|
|
d7120bb0ba | |
|
|
bf7b53c8e8 | |
|
|
1acd8846ab | |
|
|
ba26284451 | |
|
|
3f75b6d61d | |
|
|
2e1ccebe43 | |
|
|
b84c07c325 | |
|
|
94aae50564 | |
|
|
b7edba8c80 | |
|
|
2b777f5314 | |
|
|
1199eca649 | |
|
|
073777081e | |
|
|
56d4c690bf | |
|
|
efddee217a | |
|
|
8671a45d96 | |
|
|
5c99610e00 | |
|
|
153b9f0ca4 | |
|
|
95d90dcb50 | |
|
|
c6d9edbb42 | |
|
|
f81d158f0f | |
|
|
c6a2fa6808 | |
|
|
47da24a12e | |
|
|
d4bce083ab | |
|
|
5dddbaeda2 | |
|
|
3432d5189b | |
|
|
1516f2807c |
|
|
@ -0,0 +1,32 @@
|
||||||
|
# 설계 에이전트 (Design Agent)
|
||||||
|
|
||||||
|
Python과 FastAPI 전문 설계자로서, 비동기 프로그래밍, 디자인 패턴, 데이터베이스에 대한 전문적인 지식을 보유하고 있습니다.
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
- 사용자의 요구사항을 분석하고 설계 문서를 작성합니다
|
||||||
|
- 기존 프로젝트 패턴과 일관성 있는 아키텍처를 설계합니다
|
||||||
|
- API 엔드포인트, 데이터 모델, 서비스 레이어, 스키마를 설계합니다
|
||||||
|
|
||||||
|
## 수행 절차
|
||||||
|
|
||||||
|
### 1단계: 요구사항 분석
|
||||||
|
- 사용자의 요구사항을 명확히 파악합니다
|
||||||
|
- 기능적 요구사항과 비기능적 요구사항을 분리합니다
|
||||||
|
|
||||||
|
### 2단계: 관련 코드 검토
|
||||||
|
- 프로젝트의 기존 구조와 패턴을 분석합니다
|
||||||
|
- `app/` 디렉토리의 모듈 구조를 확인합니다
|
||||||
|
|
||||||
|
### 3단계: 설계 수행
|
||||||
|
다음 원칙을 준수하여 설계합니다:
|
||||||
|
- **레이어드 아키텍처**: Router → Service → Repository 패턴
|
||||||
|
- **비동기 우선**: 모든 I/O 작업은 async/await 사용
|
||||||
|
- **의존성 주입**: FastAPI의 Depends 활용
|
||||||
|
|
||||||
|
### 4단계: 설계 검수
|
||||||
|
- 기존 프로젝트 패턴과 일관성 확인
|
||||||
|
- N+1 쿼리 문제 검토
|
||||||
|
- SOLID 원칙 준수 여부 확인
|
||||||
|
|
||||||
|
## 출력
|
||||||
|
설계 문서를 화면에 출력합니다.
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# 개발 에이전트 (Development Agent)
|
||||||
|
|
||||||
|
Python과 FastAPI 전문 개발자로서, 비동기 프로그래밍과 디자인 패턴에 대한 전문적인 지식을 보유하고 있습니다.
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
- 설계 문서를 바탕으로 코드를 구현합니다
|
||||||
|
- 프로젝트 컨벤션을 준수하여 개발합니다
|
||||||
|
- 비동기 처리 패턴과 예외 처리를 적용합니다
|
||||||
|
|
||||||
|
## 코딩 표준
|
||||||
|
|
||||||
|
### Docstring
|
||||||
|
```python
|
||||||
|
async def create_user(self, user_data: UserCreate) -> User:
|
||||||
|
"""
|
||||||
|
새로운 사용자를 생성합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_data: 사용자 생성 데이터
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
생성된 User 객체
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로깅
|
||||||
|
```python
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
logger.debug(f"[1/3] 작업 시작: id={id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 비동기 병렬 처리
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
user, orders, stats = await asyncio.gather(
|
||||||
|
user_task, orders_task, stats_task
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 구현 순서
|
||||||
|
1. 모델 (models.py)
|
||||||
|
2. 스키마 (schemas/)
|
||||||
|
3. 서비스 (services/)
|
||||||
|
4. 라우터 (api/routers/)
|
||||||
|
5. 의존성 (dependencies.py)
|
||||||
|
|
||||||
|
## 검수 항목
|
||||||
|
- import 문이 올바른가?
|
||||||
|
- 타입 힌트가 정확한가?
|
||||||
|
- 비동기 함수에 await가 누락되지 않았는가?
|
||||||
|
- 순환 참조가 발생하지 않는가?
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# 코드리뷰 에이전트 (Code Review Agent)
|
||||||
|
|
||||||
|
Python과 FastAPI 전문 개발자로서, 수정된 파일들을 엔드포인트부터 흐름을 추적하여 문제점을 분석하고 개선사항을 리포트합니다.
|
||||||
|
|
||||||
|
**중요**: 이 에이전트는 파일을 수정하거나 생성하지 않습니다. 오직 분석 결과를 화면에 출력합니다.
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
- 변경된 코드의 전체 흐름을 추적합니다
|
||||||
|
- 보안, 성능, 코드 품질을 검사합니다
|
||||||
|
- 개선사항을 도출하여 리포트합니다
|
||||||
|
|
||||||
|
## 흐름 추적
|
||||||
|
```
|
||||||
|
Request → Router → Dependency → Service → Repository → Database
|
||||||
|
↓
|
||||||
|
Response ← Router ← Service ← Repository ←
|
||||||
|
```
|
||||||
|
|
||||||
|
## 검사 항목
|
||||||
|
|
||||||
|
### 보안 검사
|
||||||
|
- SQL Injection 취약점
|
||||||
|
- XSS 취약점
|
||||||
|
- 인증/인가 누락
|
||||||
|
- 민감 정보 노출
|
||||||
|
|
||||||
|
### 성능 검사
|
||||||
|
- N+1 쿼리 문제
|
||||||
|
- 불필요한 DB 호출
|
||||||
|
- 비동기 처리 누락
|
||||||
|
- 캐싱 가능 여부
|
||||||
|
|
||||||
|
### 코드 품질 검사
|
||||||
|
- 타입 힌트 정확성
|
||||||
|
- 예외 처리 적절성
|
||||||
|
- 로깅 충분성
|
||||||
|
- SOLID 원칙 준수
|
||||||
|
|
||||||
|
## 심각도 정의
|
||||||
|
- 🔴 Critical: 보안 취약점, 데이터 손실 가능성
|
||||||
|
- 🟡 Warning: 성능 저하, 유지보수성 저하
|
||||||
|
- 🟢 Info: 코드 스타일, 베스트 프랙티스 권장
|
||||||
|
|
||||||
|
## 출력
|
||||||
|
코드 리뷰 리포트를 화면에 출력합니다.
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
# 설계 에이전트 (Design Agent)
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
Python과 FastAPI 전문 설계자로서, 비동기 프로그래밍, 디자인 패턴, 데이터베이스에 대한 전문적인 지식을 보유하고 있습니다.
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
사용자 요구사항: $ARGUMENTS
|
||||||
|
|
||||||
|
## 수행 절차
|
||||||
|
|
||||||
|
### 1단계: 요구사항 분석
|
||||||
|
- 사용자의 요구사항을 명확히 파악합니다
|
||||||
|
- 기능적 요구사항과 비기능적 요구사항을 분리합니다
|
||||||
|
- 모호한 부분이 있다면 명확히 정의합니다
|
||||||
|
|
||||||
|
### 2단계: 관련 코드 검토 및 학습
|
||||||
|
- 프로젝트의 기존 구조와 패턴을 분석합니다
|
||||||
|
- 관련된 기존 코드들을 검토합니다:
|
||||||
|
- `app/` 디렉토리의 모듈 구조
|
||||||
|
- `app/core/` 핵심 유틸리티
|
||||||
|
- `app/database/` DB 설정
|
||||||
|
- `app/dependencies/` 의존성 주입 패턴
|
||||||
|
- 관련 도메인 모듈 (home, lyric, song, video, auth 등)
|
||||||
|
- 기존 서비스 레이어 패턴을 확인합니다
|
||||||
|
|
||||||
|
### 3단계: 설계 수행
|
||||||
|
다음 원칙을 준수하여 설계합니다:
|
||||||
|
|
||||||
|
#### 아키텍처 원칙
|
||||||
|
- **레이어드 아키텍처**: Router → Service → Repository 패턴
|
||||||
|
- **비동기 우선**: 모든 I/O 작업은 async/await 사용
|
||||||
|
- **의존성 주입**: FastAPI의 Depends 활용
|
||||||
|
- **단일 책임 원칙**: 각 컴포넌트는 하나의 책임만 가짐
|
||||||
|
|
||||||
|
#### 설계 산출물
|
||||||
|
1. **API 엔드포인트 설계**
|
||||||
|
- HTTP 메서드, 경로, 요청/응답 스키마
|
||||||
|
|
||||||
|
2. **데이터 모델 설계**
|
||||||
|
- SQLAlchemy 모델 정의
|
||||||
|
- 테이블 관계 설계
|
||||||
|
|
||||||
|
3. **서비스 레이어 설계**
|
||||||
|
- 비즈니스 로직 구조
|
||||||
|
- 트랜잭션 경계
|
||||||
|
|
||||||
|
4. **스키마 설계**
|
||||||
|
- Pydantic v2 모델
|
||||||
|
- 요청/응답 DTO
|
||||||
|
|
||||||
|
5. **파일 구조**
|
||||||
|
- 생성/수정될 파일 목록
|
||||||
|
- 각 파일의 역할
|
||||||
|
|
||||||
|
### 4단계: 설계 검수 (필수)
|
||||||
|
설계 완료 후 다음 항목을 점검합니다:
|
||||||
|
|
||||||
|
#### 검수 체크리스트
|
||||||
|
- [ ] 기존 프로젝트 패턴과 일관성이 있는가?
|
||||||
|
- [ ] 비동기 처리가 적절히 설계되었는가?
|
||||||
|
- [ ] N+1 쿼리 문제가 발생하지 않는가?
|
||||||
|
- [ ] 트랜잭션 경계가 명확한가?
|
||||||
|
- [ ] 예외 처리 전략이 포함되어 있는가?
|
||||||
|
- [ ] 확장성을 고려했는가?
|
||||||
|
- [ ] 개발자가 쉽게 이해할 수 있는 직관적인 구조인가?
|
||||||
|
- [ ] SOLID 원칙을 준수하는가?
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
|
||||||
|
```
|
||||||
|
## 📋 설계 문서
|
||||||
|
|
||||||
|
### 1. 요구사항 요약
|
||||||
|
[요구사항 정리]
|
||||||
|
|
||||||
|
### 2. 설계 개요
|
||||||
|
[전체적인 설계 방향]
|
||||||
|
|
||||||
|
### 3. API 설계
|
||||||
|
[엔드포인트 상세]
|
||||||
|
|
||||||
|
### 4. 데이터 모델
|
||||||
|
[모델 설계]
|
||||||
|
|
||||||
|
### 5. 서비스 레이어
|
||||||
|
[비즈니스 로직 구조]
|
||||||
|
|
||||||
|
### 6. 스키마
|
||||||
|
[Pydantic 모델]
|
||||||
|
|
||||||
|
### 7. 파일 구조
|
||||||
|
[생성/수정 파일 목록]
|
||||||
|
|
||||||
|
### 8. 구현 순서
|
||||||
|
[개발 에이전트가 따라야 할 순서]
|
||||||
|
|
||||||
|
### 9. 설계 검수 결과
|
||||||
|
[체크리스트 결과 및 개선사항]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
설계가 완료되면 `/develop` 명령으로 개발 에이전트를 호출하여 구현을 진행합니다.
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
# 개발 에이전트 (Development Agent)
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
Python과 FastAPI 전문 개발자로서, 비동기 프로그래밍과 디자인 패턴에 대한 전문적인 지식을 보유하고 있습니다.
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
설계 문서 또는 작업 지시: $ARGUMENTS
|
||||||
|
|
||||||
|
## 수행 절차
|
||||||
|
|
||||||
|
### 1단계: 작업 분석
|
||||||
|
- 설계 에이전트의 설계 문서를 확인합니다
|
||||||
|
- 구현해야 할 항목들을 파악합니다
|
||||||
|
- 구현 순서를 결정합니다
|
||||||
|
|
||||||
|
### 2단계: 개발 수행
|
||||||
|
다음 원칙을 준수하여 개발합니다:
|
||||||
|
|
||||||
|
#### 코딩 표준
|
||||||
|
```python
|
||||||
|
# 모든 함수/클래스에 docstring 작성
|
||||||
|
async def create_user(self, user_data: UserCreate) -> User:
|
||||||
|
"""
|
||||||
|
새로운 사용자를 생성합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_data: 사용자 생성 데이터
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
생성된 User 객체
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DuplicateEmailError: 이메일이 이미 존재하는 경우
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 주석 규칙
|
||||||
|
- 복잡한 비즈니스 로직에는 단계별 주석 추가
|
||||||
|
- 왜(Why) 그렇게 했는지 설명하는 주석 우선
|
||||||
|
- 자명한 코드에는 불필요한 주석 지양
|
||||||
|
|
||||||
|
#### 디버그 로깅 규칙
|
||||||
|
```python
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
async def process_order(self, order_id: int) -> Order:
|
||||||
|
"""주문 처리"""
|
||||||
|
logger.debug(f"[1/3] 주문 처리 시작: order_id={order_id}")
|
||||||
|
|
||||||
|
# 주문 조회
|
||||||
|
order = await self.get_order(order_id)
|
||||||
|
logger.debug(f"[2/3] 주문 조회 완료: status={order.status}")
|
||||||
|
|
||||||
|
# 처리 로직
|
||||||
|
result = await self._process(order)
|
||||||
|
logger.debug(f"[3/3] 주문 처리 완료: result={result}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 비동기 처리 패턴
|
||||||
|
```python
|
||||||
|
# 병렬 처리가 가능한 경우
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def get_dashboard_data(self, user_id: int):
|
||||||
|
"""대시보드 데이터 조회 - 병렬 처리"""
|
||||||
|
user_task = self.get_user(user_id)
|
||||||
|
orders_task = self.get_user_orders(user_id)
|
||||||
|
stats_task = self.get_user_stats(user_id)
|
||||||
|
|
||||||
|
user, orders, stats = await asyncio.gather(
|
||||||
|
user_task, orders_task, stats_task
|
||||||
|
)
|
||||||
|
return DashboardData(user=user, orders=orders, stats=stats)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 예외 처리 패턴
|
||||||
|
```python
|
||||||
|
from app.core.exceptions import NotFoundError, ValidationError
|
||||||
|
|
||||||
|
async def get_user(self, user_id: int) -> User:
|
||||||
|
"""사용자 조회"""
|
||||||
|
user = await self.repository.get(user_id)
|
||||||
|
if not user:
|
||||||
|
raise NotFoundError(f"사용자를 찾을 수 없습니다: {user_id}")
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3단계: 코드 구현
|
||||||
|
파일별로 순차적으로 구현합니다:
|
||||||
|
|
||||||
|
1. **모델 (models.py)**
|
||||||
|
- SQLAlchemy 모델 정의
|
||||||
|
- 관계 설정
|
||||||
|
|
||||||
|
2. **스키마 (schemas/)**
|
||||||
|
- Pydantic 요청/응답 모델
|
||||||
|
|
||||||
|
3. **서비스 (services/)**
|
||||||
|
- 비즈니스 로직 구현
|
||||||
|
- 트랜잭션 관리
|
||||||
|
|
||||||
|
4. **라우터 (api/routers/)**
|
||||||
|
- 엔드포인트 정의
|
||||||
|
- 의존성 주입
|
||||||
|
|
||||||
|
5. **의존성 (dependencies.py)**
|
||||||
|
- 서비스 주입 함수
|
||||||
|
|
||||||
|
### 4단계: 코드 검수 (필수)
|
||||||
|
모든 작업 완료 후 다음을 수행합니다:
|
||||||
|
|
||||||
|
#### 검수 항목
|
||||||
|
- [ ] 모든 파일이 정상적으로 생성/수정되었는가?
|
||||||
|
- [ ] import 문이 올바른가?
|
||||||
|
- [ ] 타입 힌트가 정확한가?
|
||||||
|
- [ ] 비동기 함수에 await가 누락되지 않았는가?
|
||||||
|
- [ ] 관련 함수들과의 호출 관계가 정상인가?
|
||||||
|
- [ ] 순환 참조가 발생하지 않는가?
|
||||||
|
- [ ] 기존 코드와의 호환성이 유지되는가?
|
||||||
|
|
||||||
|
#### 의존성 확인
|
||||||
|
```
|
||||||
|
수정된 파일 → 이 파일을 import하는 파일들 확인 → 문제 없는지 검증
|
||||||
|
```
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
|
||||||
|
```
|
||||||
|
## 🛠️ 개발 완료 보고서
|
||||||
|
|
||||||
|
### 1. 구현 요약
|
||||||
|
[구현된 기능 요약]
|
||||||
|
|
||||||
|
### 2. 생성/수정된 파일
|
||||||
|
| 파일 | 작업 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| app/xxx/models.py | 생성 | ... |
|
||||||
|
|
||||||
|
### 3. 주요 코드 설명
|
||||||
|
[핵심 로직 설명]
|
||||||
|
|
||||||
|
### 4. 디버그 포인트
|
||||||
|
[로깅이 추가된 주요 지점]
|
||||||
|
|
||||||
|
### 5. 코드 검수 결과
|
||||||
|
[검수 결과 및 확인 사항]
|
||||||
|
|
||||||
|
### 6. 주의사항
|
||||||
|
[사용 시 주의할 점]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
개발이 완료되면 `/review` 명령으로 코드리뷰 에이전트를 호출하여 최종 검수를 진행합니다.
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
# 코드리뷰 에이전트 (Code Review Agent)
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
Python과 FastAPI 전문 개발자로서, 수정된 파일들을 엔드포인트부터 흐름을 추적하여 문제점을 분석하고 개선사항을 리포트합니다.
|
||||||
|
|
||||||
|
**중요**: 이 에이전트는 파일을 수정하거나 생성하지 않습니다. 오직 분석 결과를 화면에 출력합니다.
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
리뷰 대상 파일 또는 기능: $ARGUMENTS
|
||||||
|
|
||||||
|
## 수행 절차
|
||||||
|
|
||||||
|
### 1단계: 변경 파일 식별
|
||||||
|
- 리뷰 대상 파일들을 확인합니다
|
||||||
|
- `git diff` 또는 명시된 파일 목록을 기준으로 합니다
|
||||||
|
|
||||||
|
### 2단계: 엔드포인트 흐름 추적
|
||||||
|
변경된 코드가 호출되는 전체 흐름을 추적합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
Request → Router → Dependency → Service → Repository → Database
|
||||||
|
↓
|
||||||
|
Response ← Router ← Service ← Repository ←
|
||||||
|
```
|
||||||
|
|
||||||
|
각 단계에서 확인할 사항:
|
||||||
|
- **Router**: 엔드포인트 정의, 요청/응답 스키마, 상태 코드
|
||||||
|
- **Dependency**: 인증, 권한, DB 세션 주입
|
||||||
|
- **Service**: 비즈니스 로직, 트랜잭션 경계
|
||||||
|
- **Repository/Model**: 쿼리 효율성, 관계 로딩
|
||||||
|
|
||||||
|
### 3단계: 코드 품질 검사
|
||||||
|
|
||||||
|
#### 3.1 보안 검사
|
||||||
|
- [ ] SQL Injection 취약점
|
||||||
|
- [ ] XSS 취약점
|
||||||
|
- [ ] 인증/인가 누락
|
||||||
|
- [ ] 민감 정보 노출
|
||||||
|
- [ ] Rate Limiting 적용 여부
|
||||||
|
|
||||||
|
#### 3.2 성능 검사
|
||||||
|
- [ ] N+1 쿼리 문제
|
||||||
|
- [ ] 불필요한 DB 호출
|
||||||
|
- [ ] 비동기 처리 누락 (sync in async)
|
||||||
|
- [ ] 메모리 누수 가능성
|
||||||
|
- [ ] 캐싱 가능 여부
|
||||||
|
|
||||||
|
#### 3.3 코드 품질 검사
|
||||||
|
- [ ] 타입 힌트 정확성
|
||||||
|
- [ ] 예외 처리 적절성
|
||||||
|
- [ ] 로깅 충분성
|
||||||
|
- [ ] 코드 중복
|
||||||
|
- [ ] SOLID 원칙 준수
|
||||||
|
|
||||||
|
#### 3.4 FastAPI 베스트 프랙티스
|
||||||
|
- [ ] Pydantic 모델 활용
|
||||||
|
- [ ] 의존성 주입 패턴
|
||||||
|
- [ ] 응답 모델 정의
|
||||||
|
- [ ] OpenAPI 문서화
|
||||||
|
- [ ] 비동기 컨텍스트 관리
|
||||||
|
|
||||||
|
#### 3.5 SQLAlchemy 베스트 프랙티스
|
||||||
|
- [ ] 세션 관리
|
||||||
|
- [ ] Eager/Lazy 로딩 전략
|
||||||
|
- [ ] 트랜잭션 관리
|
||||||
|
- [ ] 관계 정의
|
||||||
|
|
||||||
|
### 4단계: 개선사항 도출
|
||||||
|
발견된 문제점에 대해 구체적인 개선 방안을 제시합니다.
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
|
||||||
|
```
|
||||||
|
## 📝 코드 리뷰 리포트
|
||||||
|
|
||||||
|
### 1. 리뷰 대상
|
||||||
|
| 파일 | 변경 유형 |
|
||||||
|
|------|----------|
|
||||||
|
| app/xxx/... | 생성/수정 |
|
||||||
|
|
||||||
|
### 2. 흐름 분석
|
||||||
|
[엔드포인트별 흐름 다이어그램]
|
||||||
|
|
||||||
|
### 3. 검사 결과
|
||||||
|
|
||||||
|
#### 🔴 Critical (즉시 수정 필요)
|
||||||
|
| 파일:라인 | 문제 | 설명 | 개선 방안 |
|
||||||
|
|-----------|------|------|----------|
|
||||||
|
|
||||||
|
#### 🟡 Warning (권장 수정)
|
||||||
|
| 파일:라인 | 문제 | 설명 | 개선 방안 |
|
||||||
|
|-----------|------|------|----------|
|
||||||
|
|
||||||
|
#### 🟢 Info (참고 사항)
|
||||||
|
| 파일:라인 | 내용 |
|
||||||
|
|-----------|------|
|
||||||
|
|
||||||
|
### 4. 성능 분석
|
||||||
|
[잠재적 성능 이슈 및 최적화 제안]
|
||||||
|
|
||||||
|
### 5. 보안 분석
|
||||||
|
[보안 관련 검토 결과]
|
||||||
|
|
||||||
|
### 6. 전체 평가
|
||||||
|
- 코드 품질: ⭐⭐⭐⭐☆
|
||||||
|
- 보안: ⭐⭐⭐⭐⭐
|
||||||
|
- 성능: ⭐⭐⭐☆☆
|
||||||
|
- 가독성: ⭐⭐⭐⭐☆
|
||||||
|
|
||||||
|
### 7. 요약
|
||||||
|
[전체 리뷰 요약 및 주요 권고사항]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 심각도 정의
|
||||||
|
|
||||||
|
| 심각도 | 설명 |
|
||||||
|
|--------|------|
|
||||||
|
| 🔴 Critical | 보안 취약점, 데이터 손실 가능성, 서비스 장애 유발 |
|
||||||
|
| 🟡 Warning | 성능 저하, 유지보수성 저하, 잠재적 버그 |
|
||||||
|
| 🟢 Info | 코드 스타일, 개선 제안, 베스트 프랙티스 권장 |
|
||||||
|
|
||||||
|
## 참고 사항
|
||||||
|
- 이 에이전트는 **읽기 전용**입니다
|
||||||
|
- 파일을 직접 수정하지 않습니다
|
||||||
|
- 발견된 문제는 개발 에이전트(`/develop`)를 통해 수정합니다
|
||||||
|
|
@ -6,10 +6,12 @@ __pycache__/
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
|
.env*
|
||||||
|
|
||||||
# Claude AI related files
|
# Claude AI related files
|
||||||
.claude/
|
|
||||||
.claudeignore
|
.claudeignore
|
||||||
|
CLAUDE.md
|
||||||
|
.claude/
|
||||||
|
|
||||||
# VSCode settings
|
# VSCode settings
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
@ -27,3 +29,24 @@ build/
|
||||||
*.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
|
3.13.11
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# CLAUDE.md - O2O Castad Backend 프로젝트 가이드
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
Python FastAPI 기반의 O2O Castad 백엔드 서비스
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
- **언어**: Python 3.13
|
||||||
|
- **프레임워크**: FastAPI
|
||||||
|
- **ORM**: SQLAlchemy (비동기)
|
||||||
|
- **데이터베이스**: PostgreSQL, Redis
|
||||||
|
- **패키지 관리**: uv
|
||||||
|
|
||||||
|
## 프로젝트 구조
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── core/ # 핵심 유틸리티 (logging, exceptions)
|
||||||
|
├── database/ # DB 세션 및 Redis 설정
|
||||||
|
├── dependencies/ # FastAPI 의존성 주입
|
||||||
|
├── home/ # 홈 모듈 (크롤링, 이미지 업로드)
|
||||||
|
├── user/ # 사용자 모듈 (카카오 로그인, JWT 인증)
|
||||||
|
├── lyric/ # 가사 모듈
|
||||||
|
├── song/ # 노래 모듈
|
||||||
|
├── video/ # 비디오 모듈
|
||||||
|
└── utils/ # 공통 유틸리티
|
||||||
|
```
|
||||||
|
|
||||||
|
## 개발 컨벤션
|
||||||
|
- 모든 DB 작업은 비동기(async/await) 사용
|
||||||
|
- 서비스 레이어 패턴 적용 (routers → services → models)
|
||||||
|
- Pydantic v2 스키마 사용
|
||||||
|
- 타입 힌트 필수
|
||||||
|
|
||||||
|
## 에이전트 워크플로우
|
||||||
|
|
||||||
|
모든 개발 요청은 다음 3단계 에이전트 파이프라인을 통해 처리됩니다:
|
||||||
|
|
||||||
|
### 1단계: 설계 에이전트 (`/design`)
|
||||||
|
### 2단계: 개발 에이전트 (`/develop`)
|
||||||
|
### 3단계: 코드리뷰 에이전트 (`/review`)
|
||||||
|
|
||||||
|
각 에이전트는 `.claude/commands/` 폴더의 슬래시 커맨드로 호출할 수 있습니다.
|
||||||
|
|
||||||
|
## 주요 명령어
|
||||||
|
```bash
|
||||||
|
# 개발 서버 실행
|
||||||
|
uv run uvicorn main:app --reload
|
||||||
|
|
||||||
|
# 테스트 실행
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# 린트
|
||||||
|
uv run ruff check .
|
||||||
|
```
|
||||||
202
README.md
202
README.md
|
|
@ -4,24 +4,29 @@ AI 기반 광고 음악 생성 서비스의 백엔드 API 서버입니다.
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
|
|
||||||
|
- **Language**: Python 3.13
|
||||||
- **Framework**: FastAPI
|
- **Framework**: FastAPI
|
||||||
- **Database**: MySQL (asyncmy 비동기 드라이버)
|
- **Database**: MySQL (asyncmy 비동기 드라이버), Redis
|
||||||
- **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/ # 핵심 설정 및 공통 모듈
|
├── core/ # 핵심 설정 및 공통 모듈 (logging, exceptions)
|
||||||
├── database/ # 데이터베이스 세션 및 설정
|
├── database/ # 데이터베이스 세션 및 Redis 설정
|
||||||
|
├── 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 엔드포인트
|
||||||
|
|
@ -50,29 +55,90 @@ 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
|
# ================================
|
||||||
DEBUG=True
|
PROJECT_NAME=CastAD # 프로젝트 이름
|
||||||
|
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_PORT=3306
|
MYSQL_HOST=localhost # MySQL 호스트 주소
|
||||||
MYSQL_USER=your_user
|
MYSQL_PORT=3306 # MySQL 포트 번호
|
||||||
MYSQL_PASSWORD=your_password
|
MYSQL_USER=castad-admin # MySQL 사용자명
|
||||||
MYSQL_DB=castad
|
MYSQL_PASSWORD=o2o1324 # MySQL 비밀번호
|
||||||
|
MYSQL_DB=castad # 사용할 데이터베이스명
|
||||||
|
|
||||||
# API Keys
|
# ================================
|
||||||
CHATGPT_API_KEY=your_openai_api_key
|
# Redis 설정
|
||||||
SUNO_API_KEY=your_suno_api_key
|
# ================================
|
||||||
SUNO_CALLBACK_URL=https://your-domain.com/api/suno/callback
|
REDIS_HOST=localhost # Redis 호스트 주소
|
||||||
|
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 존재 시: 자동으로 해당 경로 사용 (운영)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 실행 방법
|
## 실행 방법
|
||||||
|
|
@ -95,6 +161,9 @@ uv sync
|
||||||
|
|
||||||
# 이미 venv를 만든 경우 (기존 가상환경 활성화 필요)
|
# 이미 venv를 만든 경우 (기존 가상환경 활성화 필요)
|
||||||
uv sync --active
|
uv sync --active
|
||||||
|
|
||||||
|
playwright install
|
||||||
|
playwright install-deps
|
||||||
```
|
```
|
||||||
|
|
||||||
### 서버 실행
|
### 서버 실행
|
||||||
|
|
@ -110,3 +179,100 @@ 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 토큰 발급 │ │ │
|
||||||
|
│◀───────────────│ │ │
|
||||||
|
│ │ │ │
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ 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.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
|
||||||
from app.video.api.video_admin import VideoAdmin
|
from app.video.api.video_admin import VideoAdmin
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
||||||
|
|
@ -35,4 +37,12 @@ def init_admin(
|
||||||
# 영상 관리
|
# 영상 관리
|
||||||
admin.add_view(VideoAdmin)
|
admin.add_view(VideoAdmin)
|
||||||
|
|
||||||
|
# 사용자 관리
|
||||||
|
admin.add_view(UserAdmin)
|
||||||
|
admin.add_view(RefreshTokenAdmin)
|
||||||
|
admin.add_view(SocialAccountAdmin)
|
||||||
|
|
||||||
|
# SNS 관리
|
||||||
|
admin.add_view(SNSUploadTaskAdmin)
|
||||||
|
|
||||||
return admin
|
return admin
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,349 @@
|
||||||
|
"""
|
||||||
|
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)}",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
@ -4,12 +4,16 @@ 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
|
||||||
|
logger = get_logger("core")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""FastAPI 애플리케이션 생명주기 관리"""
|
"""FastAPI 애플리케이션 생명주기 관리"""
|
||||||
# Startup - 애플리케이션 시작 시
|
# Startup - 애플리케이션 시작 시
|
||||||
print("Starting up...")
|
logger.info("Starting up...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
@ -19,24 +23,33 @@ async def lifespan(app: FastAPI):
|
||||||
from app.database.session import create_db_tables
|
from app.database.session import create_db_tables
|
||||||
|
|
||||||
await create_db_tables()
|
await create_db_tables()
|
||||||
print("Database tables created (DEBUG mode)")
|
logger.info("Database tables created (DEBUG mode)")
|
||||||
|
await NvMapPwScraper.initiate_scraper()
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
print("Database initialization timed out")
|
logger.error("Database initialization timed out")
|
||||||
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Database initialization failed: {e}")
|
logger.error(f"Database initialization failed: {e}")
|
||||||
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
yield # 애플리케이션 실행 중
|
yield # 애플리케이션 실행 중
|
||||||
|
|
||||||
# Shutdown - 애플리케이션 종료 시
|
# Shutdown - 애플리케이션 종료 시
|
||||||
print("Shutting down...")
|
logger.info("Shutting down...")
|
||||||
from app.database.session import engine
|
|
||||||
|
|
||||||
await engine.dispose()
|
# 공유 HTTP 클라이언트 종료
|
||||||
print("Database engine disposed")
|
from app.utils.creatomate import close_shared_client
|
||||||
|
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 적용)
|
# FastAPI 앱 생성 (lifespan 적용)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,17 @@
|
||||||
|
import traceback
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Callable, TypeVar
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response, status
|
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = get_logger("core")
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
class FastShipError(Exception):
|
class FastShipError(Exception):
|
||||||
|
|
@ -61,19 +73,193 @@ class DeliveryPartnerCapacityExceeded(FastShipError):
|
||||||
status = status.HTTP_406_NOT_ACCEPTABLE
|
status = status.HTTP_406_NOT_ACCEPTABLE
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 데이터베이스 관련 예외
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseError(FastShipError):
|
||||||
|
"""Database operation failed"""
|
||||||
|
|
||||||
|
status = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseConnectionError(DatabaseError):
|
||||||
|
"""Database connection failed"""
|
||||||
|
|
||||||
|
status = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseTimeoutError(DatabaseError):
|
||||||
|
"""Database operation timed out"""
|
||||||
|
|
||||||
|
status = status.HTTP_504_GATEWAY_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 외부 서비스 관련 예외
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalServiceError(FastShipError):
|
||||||
|
"""External service call failed"""
|
||||||
|
|
||||||
|
status = status.HTTP_502_BAD_GATEWAY
|
||||||
|
|
||||||
|
|
||||||
|
class GPTServiceError(ExternalServiceError):
|
||||||
|
"""GPT API call failed"""
|
||||||
|
|
||||||
|
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):
|
def _get_handler(status: int, detail: str):
|
||||||
# Define
|
# Define
|
||||||
def handler(request: Request, exception: Exception) -> Response:
|
def handler(request: Request, exception: Exception) -> Response:
|
||||||
# DEBUG PRINT STATEMENT 👇
|
logger.debug(f"Handled Exception: {exception.__class__.__name__}")
|
||||||
from rich import print, panel
|
|
||||||
print(
|
|
||||||
panel.Panel(
|
|
||||||
exception.__class__.__name__,
|
|
||||||
title="Handled Exception",
|
|
||||||
border_style="red",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
# DEBUG PRINT STATEMENT 👆
|
|
||||||
|
|
||||||
# Raise HTTPException with given status and detail
|
# Raise HTTPException with given status and detail
|
||||||
# can return JSONResponse as well
|
# can return JSONResponse as well
|
||||||
|
|
@ -102,13 +288,33 @@ def add_exception_handlers(app: FastAPI):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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)
|
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
def internal_server_error_handler(request, exception):
|
def internal_server_error_handler(request, exception):
|
||||||
|
# 에러 메시지 로깅 (한글 포함 가능)
|
||||||
|
logger.error(f"Internal Server Error: {exception}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"detail": "Something went wrong..."},
|
content={"detail": "Something went wrong..."},
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
headers={
|
|
||||||
"X-Error": f"{exception}",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -4,11 +4,6 @@ from redis.asyncio import Redis
|
||||||
from app.config import db_settings
|
from app.config import db_settings
|
||||||
|
|
||||||
|
|
||||||
_token_blacklist = Redis(
|
|
||||||
host=db_settings.REDIS_HOST,
|
|
||||||
port=db_settings.REDIS_PORT,
|
|
||||||
db=0,
|
|
||||||
)
|
|
||||||
_shipment_verification_codes = Redis(
|
_shipment_verification_codes = Redis(
|
||||||
host=db_settings.REDIS_HOST,
|
host=db_settings.REDIS_HOST,
|
||||||
port=db_settings.REDIS_PORT,
|
port=db_settings.REDIS_PORT,
|
||||||
|
|
@ -16,15 +11,10 @@ _shipment_verification_codes = Redis(
|
||||||
decode_responses=True,
|
decode_responses=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def add_jti_to_blacklist(jti: str):
|
|
||||||
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):
|
async def add_shipment_verification_code(id: UUID, code: int):
|
||||||
await _shipment_verification_codes.set(str(id), code)
|
await _shipment_verification_codes.set(str(id), code)
|
||||||
|
|
||||||
|
|
||||||
async def get_shipment_verification_code(id: UUID) -> str:
|
async def get_shipment_verification_code(id: UUID) -> str:
|
||||||
return str(await _shipment_verification_codes.get(str(id)))
|
return str(await _shipment_verification_codes.get(str(id)))
|
||||||
|
|
@ -9,8 +9,11 @@ from sqlalchemy.ext.asyncio import (
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
|
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
from config import db_settings
|
from config import db_settings
|
||||||
|
|
||||||
|
logger = get_logger("database")
|
||||||
|
|
||||||
|
|
||||||
# Base 클래스 정의
|
# Base 클래스 정의
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
|
|
@ -61,7 +64,7 @@ async def create_db_tables() -> None:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
# from app.database.models import Shipment, Seller # noqa: F401
|
# from app.database.models import Shipment, Seller # noqa: F401
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
print("MySQL tables created successfully")
|
logger.info("MySQL tables created successfully")
|
||||||
|
|
||||||
|
|
||||||
# 세션 제너레이터 (FastAPI Depends에 사용)
|
# 세션 제너레이터 (FastAPI Depends에 사용)
|
||||||
|
|
@ -80,13 +83,13 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
|
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback() # 명시적 롤백 (선택적)
|
await session.rollback() # 명시적 롤백 (선택적)
|
||||||
print(f"Session rollback due to: {e}") # 로깅
|
logger.error(f"Session rollback due to: {e}")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# 명시적 세션 종료 (Connection Pool에 반환)
|
# 명시적 세션 종료 (Connection Pool에 반환)
|
||||||
# context manager가 자동 처리하지만, 명시적으로 유지
|
# context manager가 자동 처리하지만, 명시적으로 유지
|
||||||
await session.close()
|
await session.close()
|
||||||
print("session closed successfully")
|
logger.debug("session closed successfully")
|
||||||
# 또는 session.aclose() - Python 3.10+
|
# 또는 session.aclose() - Python 3.10+
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -94,4 +97,4 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
async def dispose_engine() -> None:
|
async def dispose_engine() -> None:
|
||||||
"""애플리케이션 종료 시 모든 연결 해제"""
|
"""애플리케이션 종료 시 모든 연결 해제"""
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
print("Database engine disposed")
|
logger.info("Database engine disposed")
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,38 @@
|
||||||
from contextlib import asynccontextmanager
|
import time
|
||||||
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):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# 데이터베이스 엔진 생성
|
# =============================================================================
|
||||||
|
# 메인 엔진 (FastAPI 요청용)
|
||||||
|
# =============================================================================
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
url=db_settings.MYSQL_URL,
|
url=db_settings.MYSQL_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_size=10,
|
pool_size=20, # 기본 풀 크기: 20
|
||||||
max_overflow=10,
|
max_overflow=20, # 추가 연결: 20 (총 최대 40)
|
||||||
pool_timeout=5,
|
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
|
||||||
pool_recycle=3600,
|
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||||
pool_reset_on_return="rollback",
|
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
|
||||||
connect_args={
|
connect_args={
|
||||||
"connect_timeout": 3,
|
"connect_timeout": 10, # DB 연결 타임아웃
|
||||||
"charset": "utf8mb4",
|
"charset": "utf8mb4",
|
||||||
# "allow_public_key_retrieval": True,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Async sessionmaker 생성
|
# 메인 세션 팩토리 (FastAPI DI용)
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
bind=engine,
|
bind=engine,
|
||||||
class_=AsyncSession,
|
class_=AsyncSession,
|
||||||
|
|
@ -38,90 +41,147 @@ AsyncSessionLocal = async_sessionmaker(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
from app.song.models import Song # noqa: F401
|
|
||||||
from app.video.models import Video # noqa: F401
|
|
||||||
|
|
||||||
print("Creating database tables...")
|
|
||||||
|
|
||||||
async with asyncio.timeout(10):
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
|
|
||||||
# FastAPI 의존성용 세션 제너레이터
|
|
||||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
# print("Session commited")
|
|
||||||
# await session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
print(f"Session rollback due to: {e}")
|
|
||||||
raise e
|
|
||||||
# async with 종료 시 session.close()가 자동 호출됨
|
|
||||||
|
|
||||||
|
|
||||||
# 앱 종료 시 엔진 리소스 정리 함수
|
|
||||||
async def dispose_engine() -> None:
|
|
||||||
await engine.dispose()
|
|
||||||
print("Database engine disposed")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 백그라운드 태스크용 세션 (별도 이벤트 루프에서 사용)
|
# 백그라운드 태스크 전용 엔진 (메인 풀과 분리)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
background_engine = create_async_engine(
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
"""백그라운드 태스크용 세션 컨텍스트 매니저
|
|
||||||
|
|
||||||
asyncio.run()으로 새 이벤트 루프를 생성하는 백그라운드 태스크에서 사용합니다.
|
|
||||||
NullPool을 사용하여 연결 풀링을 비활성화하고, 이벤트 루프 충돌을 방지합니다.
|
|
||||||
|
|
||||||
get_session()과의 차이점:
|
|
||||||
- get_session(): FastAPI DI용, 메인 이벤트 루프의 연결 풀 사용
|
|
||||||
- get_worker_session(): 백그라운드 태스크용, NullPool로 매번 새 연결 생성
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
async with get_worker_session() as session:
|
|
||||||
result = await session.execute(select(Model))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
Note:
|
|
||||||
- 매 호출마다 엔진을 생성하고 dispose하므로 오버헤드가 있음
|
|
||||||
- 빈번한 호출이 필요한 경우 방법 1(모듈 레벨 엔진)을 고려
|
|
||||||
"""
|
|
||||||
worker_engine = create_async_engine(
|
|
||||||
url=db_settings.MYSQL_URL,
|
url=db_settings.MYSQL_URL,
|
||||||
poolclass=NullPool,
|
echo=False,
|
||||||
|
pool_size=10, # 백그라운드용 풀 크기: 10
|
||||||
|
max_overflow=10, # 추가 연결: 10 (총 최대 20)
|
||||||
|
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
|
||||||
|
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
||||||
|
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||||
|
pool_reset_on_return="rollback",
|
||||||
connect_args={
|
connect_args={
|
||||||
"connect_timeout": 3,
|
"connect_timeout": 10,
|
||||||
"charset": "utf8mb4",
|
"charset": "utf8mb4",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
session_factory = async_sessionmaker(
|
|
||||||
bind=worker_engine,
|
# 백그라운드 세션 팩토리
|
||||||
|
BackgroundSessionLocal = async_sessionmaker(
|
||||||
|
bind=background_engine,
|
||||||
class_=AsyncSession,
|
class_=AsyncSession,
|
||||||
expire_on_commit=False,
|
expire_on_commit=False,
|
||||||
autoflush=False,
|
autoflush=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with session_factory() as session:
|
|
||||||
|
async def create_db_tables():
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# 모델 import (테이블 메타데이터 등록용)
|
||||||
|
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
|
||||||
|
|
||||||
|
# 생성할 테이블 목록
|
||||||
|
tables_to_create = [
|
||||||
|
User.__table__,
|
||||||
|
RefreshToken.__table__,
|
||||||
|
SocialAccount.__table__,
|
||||||
|
Project.__table__,
|
||||||
|
Image.__table__,
|
||||||
|
Lyric.__table__,
|
||||||
|
Song.__table__,
|
||||||
|
SongTimestamp.__table__,
|
||||||
|
Video.__table__,
|
||||||
|
SNSUploadTask.__table__,
|
||||||
|
SocialUpload.__table__,
|
||||||
|
MarketingIntel.__table__,
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info("Creating database tables...")
|
||||||
|
|
||||||
|
async with asyncio.timeout(10):
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(
|
||||||
|
lambda conn: Base.metadata.create_all(conn, tables=tables_to_create)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# FastAPI 의존성용 세션 제너레이터
|
||||||
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
pool = engine.pool
|
||||||
|
|
||||||
|
# 커넥션 풀 상태 로깅 (디버깅용)
|
||||||
|
# logger.debug(
|
||||||
|
# f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||||
|
# f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||||
|
# f"overflow: {pool.overflow()}"
|
||||||
|
# )
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
acquire_time = time.perf_counter()
|
||||||
|
# logger.debug(
|
||||||
|
# f"[get_session] Session acquired in "
|
||||||
|
# f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||||
|
# )
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
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:
|
try:
|
||||||
yield session
|
yield session
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
print(f"Worker session rollback due to: {e}")
|
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
|
raise e
|
||||||
finally:
|
finally:
|
||||||
await session.close()
|
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()}"
|
||||||
|
# )
|
||||||
|
|
||||||
await worker_engine.dispose()
|
|
||||||
|
# 앱 종료 시 엔진 리소스 정리 함수
|
||||||
|
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,15 +1,3 @@
|
||||||
"""API 1 Version Router Module."""
|
"""
|
||||||
|
Home API v1 라우터 모듈
|
||||||
# from fastapi import APIRouter, Depends
|
"""
|
||||||
|
|
||||||
# API 버전 1 라우터를 정의합니다.
|
|
||||||
# router = APIRouter(
|
|
||||||
# prefix="/api/v1",
|
|
||||||
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
|
||||||
# )
|
|
||||||
# router = APIRouter(
|
|
||||||
# prefix="/api/v1",
|
|
||||||
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
|
||||||
# )
|
|
||||||
# router.include_router(auth.router, tags=[Tags.AUTH])
|
|
||||||
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD])
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -6,26 +7,38 @@ from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session, AsyncSessionLocal
|
||||||
from app.home.models import Image
|
from app.home.models import Image, MarketingIntel
|
||||||
|
from app.user.dependencies.auth import get_current_user
|
||||||
|
from app.user.models import User
|
||||||
from app.home.schemas.home_schema import (
|
from app.home.schemas.home_schema import (
|
||||||
|
AutoCompleteRequest,
|
||||||
|
AccommodationSearchItem,
|
||||||
|
AccommodationSearchResponse,
|
||||||
CrawlingRequest,
|
CrawlingRequest,
|
||||||
CrawlingResponse,
|
CrawlingResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
ImageUploadResponse,
|
ImageUploadResponse,
|
||||||
ImageUploadResultItem,
|
ImageUploadResultItem,
|
||||||
ImageUrlItem,
|
ImageUrlItem,
|
||||||
MarketingAnalysis,
|
|
||||||
ProcessedInfo,
|
ProcessedInfo,
|
||||||
|
# MarketingAnalysis,
|
||||||
)
|
)
|
||||||
|
from app.home.services.naver_search import naver_search_client
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
from app.utils.chatgpt_prompt import ChatgptService
|
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||||
from app.utils.common import generate_task_id
|
from app.utils.common import generate_task_id
|
||||||
from app.utils.nvMapScraper import NvMapScraper
|
from app.utils.logger import get_logger
|
||||||
|
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
|
||||||
|
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||||
|
from app.utils.prompts.prompts import marketing_prompt
|
||||||
|
from config import MEDIA_ROOT
|
||||||
|
|
||||||
MEDIA_ROOT = Path("media")
|
# 로거 설정
|
||||||
|
logger = get_logger("home")
|
||||||
|
|
||||||
# 전국 시 이름 목록 (roadAddress에서 region 추출용)
|
# 전국 시 이름 목록 (roadAddress에서 region 추출용)
|
||||||
# fmt: off
|
# fmt: off
|
||||||
|
|
@ -56,9 +69,51 @@ KOREAN_CITIES = [
|
||||||
]
|
]
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
|
# router = APIRouter(tags=["Home"])
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/search/accommodation",
|
||||||
|
summary="숙박/펜션 자동완성 검색",
|
||||||
|
description="""
|
||||||
|
네이버 지역 검색 API를 이용한 숙박/펜션 자동완성 검색입니다.
|
||||||
|
|
||||||
|
## 요청 파라미터
|
||||||
|
- **query**: 검색어 (필수)
|
||||||
|
|
||||||
|
## 반환 정보
|
||||||
|
- **query**: 검색어
|
||||||
|
- **count**: 검색 결과 수 (최대 10개)
|
||||||
|
- **items**: 검색 결과 목록
|
||||||
|
- **title**: 숙소명 (HTML 태그 포함 가능)
|
||||||
|
- **address**: 지번 주소
|
||||||
|
- **roadAddress**: 도로명 주소
|
||||||
|
""",
|
||||||
|
response_model=AccommodationSearchResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "검색 성공", "model": AccommodationSearchResponse},
|
||||||
|
},
|
||||||
|
tags=["Search"],
|
||||||
|
)
|
||||||
|
async def search_accommodation(
|
||||||
|
query: str,
|
||||||
|
) -> AccommodationSearchResponse:
|
||||||
|
"""숙박/펜션 자동완성 검색"""
|
||||||
|
results = await naver_search_client.search_accommodation(
|
||||||
|
query=query,
|
||||||
|
display=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
items = [AccommodationSearchItem(**item) for item in results]
|
||||||
|
|
||||||
|
return AccommodationSearchResponse(
|
||||||
|
query=query,
|
||||||
|
count=len(items),
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_region_from_address(road_address: str | None) -> str:
|
def _extract_region_from_address(road_address: str | None) -> str:
|
||||||
"""roadAddress에서 시 이름 추출"""
|
"""roadAddress에서 시 이름 추출"""
|
||||||
if not road_address:
|
if not road_address:
|
||||||
|
|
@ -91,15 +146,99 @@ def _extract_region_from_address(road_address: str | None) -> str:
|
||||||
"description": "잘못된 URL",
|
"description": "잘못된 URL",
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
},
|
},
|
||||||
|
502: {
|
||||||
|
"description": "크롤링 실패",
|
||||||
|
"model": ErrorResponse,
|
||||||
},
|
},
|
||||||
tags=["crawling"],
|
},
|
||||||
|
tags=["Crawling"],
|
||||||
)
|
)
|
||||||
async def crawling(request_body: CrawlingRequest):
|
async def crawling(
|
||||||
"""네이버 지도 장소 크롤링"""
|
request_body: CrawlingRequest,
|
||||||
scraper = NvMapScraper(request_body.url)
|
session: AsyncSession = Depends(get_session)):
|
||||||
await scraper.scrap()
|
return await _crawling_logic(request_body.url, session)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/autocomplete",
|
||||||
|
summary="네이버 자동완성 크롤링",
|
||||||
|
description="""
|
||||||
|
네이버 검색 API 정보를 활용하여 Place ID를 추출한 뒤 자동으로 크롤링합니다.
|
||||||
|
|
||||||
|
## 요청 필드
|
||||||
|
- **title**: 네이버 검색 API Place 결과물 title (필수)
|
||||||
|
- **address**: 네이버 검색 API Place 결과물 지번주소 (필수)
|
||||||
|
- **roadAddress**:네이버 검색 API Place 결과물 도로명주소
|
||||||
|
|
||||||
|
## 반환 정보
|
||||||
|
- **image_list**: 장소 이미지 URL 목록
|
||||||
|
- **image_count**: 이미지 개수
|
||||||
|
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
|
||||||
|
""",
|
||||||
|
response_model=CrawlingResponse,
|
||||||
|
response_description="크롤링 결과",
|
||||||
|
responses={
|
||||||
|
200: {"description": "크롤링 성공", "model": CrawlingResponse},
|
||||||
|
400: {
|
||||||
|
"description": "잘못된 URL",
|
||||||
|
"model": ErrorResponse,
|
||||||
|
},
|
||||||
|
502: {
|
||||||
|
"description": "크롤링 실패",
|
||||||
|
"model": ErrorResponse,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags=["Crawling"],
|
||||||
|
)
|
||||||
|
async def autocomplete_crawling(
|
||||||
|
request_body: AutoCompleteRequest,
|
||||||
|
session: AsyncSession = Depends(get_session)):
|
||||||
|
url = await _autocomplete_logic(request_body.model_dump())
|
||||||
|
return await _crawling_logic(url, session)
|
||||||
|
|
||||||
|
async def _crawling_logic(
|
||||||
|
url:str,
|
||||||
|
session: AsyncSession):
|
||||||
|
request_start = time.perf_counter()
|
||||||
|
logger.info("[crawling] ========== START ==========")
|
||||||
|
logger.info(f"[crawling] URL: {url[:80]}...")
|
||||||
|
|
||||||
|
# ========== Step 1: 네이버 지도 크롤링 ==========
|
||||||
|
step1_start = time.perf_counter()
|
||||||
|
logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
scraper = NvMapScraper(url)
|
||||||
|
await scraper.scrap()
|
||||||
|
except GraphQLException as e:
|
||||||
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
|
logger.error(
|
||||||
|
f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
|
logger.error(
|
||||||
|
f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
logger.exception("[crawling] Step 1 상세 오류:")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="네이버 지도 크롤링 중 오류가 발생했습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
|
image_count = len(scraper.image_link_list) if scraper.image_link_list else 0
|
||||||
|
logger.info(
|
||||||
|
f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== Step 2: 정보 가공 ==========
|
||||||
|
step2_start = time.perf_counter()
|
||||||
|
logger.info("[crawling] Step 2: 정보 가공 시작...")
|
||||||
|
|
||||||
# 가공된 정보 생성
|
|
||||||
processed_info = None
|
processed_info = None
|
||||||
marketing_analysis = None
|
marketing_analysis = None
|
||||||
|
|
||||||
|
|
@ -114,25 +253,160 @@ async def crawling(request_body: CrawlingRequest):
|
||||||
detail_region_info=road_address or "",
|
detail_region_info=road_address or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ChatGPT를 이용한 마케팅 분석
|
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||||
chatgpt_service = ChatgptService(
|
logger.info(
|
||||||
customer_name=customer_name,
|
f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)"
|
||||||
region=region,
|
|
||||||
detail_region_info=road_address or "",
|
|
||||||
)
|
)
|
||||||
prompt = chatgpt_service.build_market_analysis_prompt()
|
|
||||||
raw_response = await chatgpt_service.generate(prompt)
|
# ========== Step 3: ChatGPT 마케팅 분석 ==========
|
||||||
parsed = await chatgpt_service.parse_marketing_analysis(raw_response)
|
step3_start = time.perf_counter()
|
||||||
marketing_analysis = MarketingAnalysis(**parsed)
|
logger.info("[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 3-1: ChatGPT 서비스 초기화
|
||||||
|
step3_1_start = time.perf_counter()
|
||||||
|
chatgpt_service = ChatgptService()
|
||||||
|
step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000
|
||||||
|
logger.debug(
|
||||||
|
f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3-2: 프롬프트 생성
|
||||||
|
# step3_2_start = time.perf_counter()
|
||||||
|
input_marketing_data = {
|
||||||
|
"customer_name": customer_name,
|
||||||
|
"region": region,
|
||||||
|
"detail_region_info": road_address or "",
|
||||||
|
}
|
||||||
|
# prompt = chatgpt_service.build_market_analysis_prompt()
|
||||||
|
# prompt1 = marketing_prompt.build_prompt(input_marketing_data)
|
||||||
|
# step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000
|
||||||
|
|
||||||
|
# Step 3-3: GPT API 호출
|
||||||
|
step3_3_start = time.perf_counter()
|
||||||
|
structured_report = await chatgpt_service.generate_structured_output(
|
||||||
|
marketing_prompt, input_marketing_data
|
||||||
|
)
|
||||||
|
marketing_intelligence = MarketingIntel(
|
||||||
|
place_id = scraper.place_id,
|
||||||
|
intel_result = structured_report.model_dump()
|
||||||
|
)
|
||||||
|
session.add(marketing_intelligence)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(marketing_intelligence)
|
||||||
|
m_id = marketing_intelligence.id
|
||||||
|
logger.debug(f"[MarketingPrompt] INSERT placeid {marketing_intelligence.place_id}")
|
||||||
|
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
|
||||||
|
logger.info(
|
||||||
|
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달)
|
||||||
|
step3_4_start = time.perf_counter()
|
||||||
|
logger.debug(
|
||||||
|
f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 요약 Deprecated / 20250115 / Selling points를 첫 prompt에서 추출 중
|
||||||
|
# parsed = await chatgpt_service.parse_marketing_analysis(
|
||||||
|
# structured_report, facility_info=scraper.facility_info
|
||||||
|
# )
|
||||||
|
|
||||||
|
# marketing_analysis = MarketingAnalysis(**parsed)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"structured_report = {structured_report.model_dump()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
marketing_analysis = structured_report
|
||||||
|
|
||||||
|
step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
|
||||||
|
logger.debug(
|
||||||
|
f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
|
||||||
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||||
|
logger.info(
|
||||||
|
f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
|
||||||
|
except ChatGPTResponseError as e:
|
||||||
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||||
|
logger.error(
|
||||||
|
f"[crawling] Step 3 FAILED - ChatGPT Error: status={e.status}, "
|
||||||
|
f"code={e.error_code}, message={e.error_message} ({step3_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
marketing_analysis = None
|
||||||
|
gpt_status = "failed"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||||
|
logger.error(
|
||||||
|
f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
logger.exception("[crawling] Step 3 상세 오류:")
|
||||||
|
# GPT 실패 시에도 크롤링 결과는 반환
|
||||||
|
marketing_analysis = None
|
||||||
|
gpt_status = "failed"
|
||||||
|
else:
|
||||||
|
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||||
|
logger.warning(
|
||||||
|
f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== 완료 ==========
|
||||||
|
total_elapsed = (time.perf_counter() - request_start) * 1000
|
||||||
|
logger.info("[crawling] ========== COMPLETE ==========")
|
||||||
|
logger.info(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms")
|
||||||
|
logger.info(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
|
||||||
|
if scraper.base_info:
|
||||||
|
logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
|
||||||
|
if "step3_elapsed" in locals():
|
||||||
|
logger.info(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
|
||||||
|
if "step3_3_elapsed" in locals():
|
||||||
|
logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"status": gpt_status if 'gpt_status' in locals() else "completed",
|
||||||
"image_list": scraper.image_link_list,
|
"image_list": scraper.image_link_list,
|
||||||
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
||||||
"processed_info": processed_info,
|
"processed_info": processed_info,
|
||||||
"marketing_analysis": marketing_analysis,
|
"marketing_analysis": marketing_analysis,
|
||||||
|
"m_id" : m_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _autocomplete_logic(autocomplete_item:dict):
|
||||||
|
step1_start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
async with NvMapPwScraper() as pw_scraper:
|
||||||
|
new_url = await pw_scraper.get_place_id_url(autocomplete_item)
|
||||||
|
except Exception as e:
|
||||||
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
|
logger.error(
|
||||||
|
f"[crawling] Autocomplete FAILED - 자동완성 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
logger.exception("[crawling] Autocomplete 상세 오류:")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="자동완성 place id 추출 실패",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not new_url:
|
||||||
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
|
logger.error(
|
||||||
|
f"[crawling] Autocomplete FAILED - URL을 찾을 수 없음 ({step1_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="해당 장소의 네이버 지도 URL을 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_url
|
||||||
|
|
||||||
def _extract_image_name(url: str, index: int) -> str:
|
def _extract_image_name(url: str, index: int) -> str:
|
||||||
"""URL에서 이미지 이름 추출 또는 기본 이름 생성"""
|
"""URL에서 이미지 이름 추출 또는 기본 이름 생성"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -179,11 +453,11 @@ IMAGES_JSON_EXAMPLE = """[
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/image/upload/server/{task_id}",
|
"/image/upload/server",
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
summary="이미지 업로드 (로컬 서버)",
|
summary="이미지 업로드 (로컬 서버)",
|
||||||
description="""
|
description="""
|
||||||
task_id에 연결된 이미지를 로컬 서버(media 폴더)에 업로드합니다.
|
이미지를 로컬 서버(media 폴더)에 업로드하고 새로운 task_id를 생성합니다.
|
||||||
|
|
||||||
## 요청 방식
|
## 요청 방식
|
||||||
multipart/form-data 형식으로 전송합니다.
|
multipart/form-data 형식으로 전송합니다.
|
||||||
|
|
@ -258,27 +532,34 @@ print(response.json())
|
||||||
## 저장 경로
|
## 저장 경로
|
||||||
- 바이너리 파일: /media/image/{날짜}/{uuid7}/{파일명}
|
- 바이너리 파일: /media/image/{날짜}/{uuid7}/{파일명}
|
||||||
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
|
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
|
||||||
|
|
||||||
|
## 반환 정보
|
||||||
|
- **task_id**: 새로 생성된 작업 고유 식별자
|
||||||
|
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
|
||||||
""",
|
""",
|
||||||
response_model=ImageUploadResponse,
|
response_model=ImageUploadResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "이미지 업로드 성공"},
|
200: {"description": "이미지 업로드 성공"},
|
||||||
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
||||||
},
|
},
|
||||||
tags=["image"],
|
tags=["Image-Server"],
|
||||||
)
|
)
|
||||||
async def upload_images(
|
async def upload_images(
|
||||||
task_id: str,
|
|
||||||
images_json: Optional[str] = Form(
|
images_json: Optional[str] = Form(
|
||||||
default=None,
|
default=None,
|
||||||
description="외부 이미지 URL 목록 (JSON 문자열)",
|
description="외부 이미지 URL 목록 (JSON 문자열)",
|
||||||
example=IMAGES_JSON_EXAMPLE,
|
examples=[IMAGES_JSON_EXAMPLE],
|
||||||
),
|
),
|
||||||
files: Optional[list[UploadFile]] = File(
|
files: Optional[list[UploadFile]] = File(
|
||||||
default=None, description="이미지 바이너리 파일 목록"
|
default=None, description="이미지 바이너리 파일 목록"
|
||||||
),
|
),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> ImageUploadResponse:
|
) -> ImageUploadResponse:
|
||||||
"""이미지 업로드 (URL + 바이너리 파일)"""
|
"""이미지 업로드 (URL + 바이너리 파일)"""
|
||||||
|
# task_id 생성
|
||||||
|
task_id = await generate_task_id()
|
||||||
|
|
||||||
# 1. 진입 검증: images_json 또는 files 중 하나는 반드시 있어야 함
|
# 1. 진입 검증: images_json 또는 files 중 하나는 반드시 있어야 함
|
||||||
has_images_json = images_json is not None and images_json.strip() != ""
|
has_images_json = images_json is not None and images_json.strip() != ""
|
||||||
has_files = files is not None and len(files) > 0
|
has_files = files is not None and len(files) > 0
|
||||||
|
|
@ -405,6 +686,9 @@ async def upload_images(
|
||||||
saved_count = len(result_images)
|
saved_count = len(result_images)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
# Image 테이블에서 현재 task_id의 이미지 URL 목록 조회
|
||||||
|
image_urls = [img.img_url for img in result_images]
|
||||||
|
|
||||||
return ImageUploadResponse(
|
return ImageUploadResponse(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
total_count=len(result_images),
|
total_count=len(result_images),
|
||||||
|
|
@ -412,16 +696,20 @@ async def upload_images(
|
||||||
file_count=len(valid_files),
|
file_count=len(valid_files),
|
||||||
saved_count=saved_count,
|
saved_count=saved_count,
|
||||||
images=result_images,
|
images=result_images,
|
||||||
|
image_urls=image_urls,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/image/upload/blob/{task_id}",
|
"/image/upload/blob",
|
||||||
summary="이미지 업로드 (Azure Blob Storage)",
|
summary="이미지 업로드 (Azure Blob Storage)",
|
||||||
description="""
|
description="""
|
||||||
task_id에 연결된 이미지를 Azure Blob Storage에 업로드합니다.
|
이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다.
|
||||||
바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다.
|
바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다.
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||||
|
|
||||||
## 요청 방식
|
## 요청 방식
|
||||||
multipart/form-data 형식으로 전송합니다.
|
multipart/form-data 형식으로 전송합니다.
|
||||||
|
|
||||||
|
|
@ -447,24 +735,27 @@ jpg, jpeg, png, webp, heic, heif
|
||||||
### cURL로 테스트
|
### cURL로 테스트
|
||||||
```bash
|
```bash
|
||||||
# 바이너리 파일만 업로드
|
# 바이너리 파일만 업로드
|
||||||
curl -X POST "http://localhost:8000/image/upload/blob/test-task-001" \\
|
curl -X POST "http://localhost:8000/image/upload/blob" \\
|
||||||
|
-H "Authorization: Bearer {access_token}" \\
|
||||||
-F "files=@/path/to/image1.jpg" \\
|
-F "files=@/path/to/image1.jpg" \\
|
||||||
-F "files=@/path/to/image2.png"
|
-F "files=@/path/to/image2.png"
|
||||||
|
|
||||||
# URL + 바이너리 파일 동시 업로드
|
# URL + 바이너리 파일 동시 업로드
|
||||||
curl -X POST "http://localhost:8000/image/upload/blob/test-task-001" \\
|
curl -X POST "http://localhost:8000/image/upload/blob" \\
|
||||||
|
-H "Authorization: Bearer {access_token}" \\
|
||||||
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
|
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
|
||||||
-F "files=@/path/to/local_image.jpg"
|
-F "files=@/path/to/local_image.jpg"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **task_id**: 작업 고유 식별자
|
- **task_id**: 새로 생성된 작업 고유 식별자
|
||||||
- **total_count**: 총 업로드된 이미지 개수
|
- **total_count**: 총 업로드된 이미지 개수
|
||||||
- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장)
|
- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장)
|
||||||
- **file_count**: 파일로 업로드된 이미지 개수 (Azure Blob Storage에 저장)
|
- **file_count**: 파일로 업로드된 이미지 개수 (Azure Blob Storage에 저장)
|
||||||
- **saved_count**: Image 테이블에 저장된 row 수
|
- **saved_count**: Image 테이블에 저장된 row 수
|
||||||
- **images**: 업로드된 이미지 목록
|
- **images**: 업로드된 이미지 목록
|
||||||
- **source**: "url" (외부 URL) 또는 "blob" (Azure Blob Storage)
|
- **source**: "url" (외부 URL) 또는 "blob" (Azure Blob Storage)
|
||||||
|
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
|
||||||
|
|
||||||
## 저장 경로
|
## 저장 경로
|
||||||
- 바이너리 파일: Azure Blob Storage ({BASE_URL}/{task_id}/image/{파일명})
|
- 바이너리 파일: Azure Blob Storage ({BASE_URL}/{task_id}/image/{파일명})
|
||||||
|
|
@ -474,23 +765,45 @@ curl -X POST "http://localhost:8000/image/upload/blob/test-task-001" \\
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "이미지 업로드 성공"},
|
200: {"description": "이미지 업로드 성공"},
|
||||||
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
||||||
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||||
|
},
|
||||||
|
tags=["Image-Blob"],
|
||||||
|
openapi_extra={
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"encoding": {"files": {"contentType": "application/octet-stream"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
tags=["image"],
|
|
||||||
)
|
)
|
||||||
async def upload_images_blob(
|
async def upload_images_blob(
|
||||||
task_id: str,
|
|
||||||
images_json: Optional[str] = Form(
|
images_json: Optional[str] = Form(
|
||||||
default=None,
|
default=None,
|
||||||
description="외부 이미지 URL 목록 (JSON 문자열)",
|
description="외부 이미지 URL 목록 (JSON 문자열)",
|
||||||
example=IMAGES_JSON_EXAMPLE,
|
examples=[IMAGES_JSON_EXAMPLE],
|
||||||
),
|
),
|
||||||
files: Optional[list[UploadFile]] = File(
|
files: Optional[list[UploadFile]] = File(
|
||||||
default=None, description="이미지 바이너리 파일 목록"
|
default=None,
|
||||||
|
description="이미지 바이너리 파일 목록",
|
||||||
),
|
),
|
||||||
session: AsyncSession = Depends(get_session),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> ImageUploadResponse:
|
) -> ImageUploadResponse:
|
||||||
"""이미지 업로드 (URL + Azure Blob Storage)"""
|
"""이미지 업로드 (URL + Azure Blob Storage)
|
||||||
# 1. 진입 검증
|
|
||||||
|
3단계로 분리하여 세션 점유 시간 최소화:
|
||||||
|
- Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음)
|
||||||
|
- Stage 2: Azure Blob 업로드 (세션 없음)
|
||||||
|
- Stage 3: DB 저장 (새 세션으로 빠르게 처리)
|
||||||
|
"""
|
||||||
|
request_start = time.perf_counter()
|
||||||
|
|
||||||
|
# task_id 생성
|
||||||
|
task_id = await generate_task_id()
|
||||||
|
logger.info(f"[upload_images_blob] START - task_id: {task_id}")
|
||||||
|
|
||||||
|
# ========== Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) ==========
|
||||||
has_images_json = images_json is not None and images_json.strip() != ""
|
has_images_json = images_json is not None and images_json.strip() != ""
|
||||||
has_files = files is not None and len(files) > 0
|
has_files = files is not None and len(files) > 0
|
||||||
|
|
||||||
|
|
@ -500,9 +813,9 @@ async def upload_images_blob(
|
||||||
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
|
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. images_json 파싱
|
# images_json 파싱
|
||||||
url_images: list[ImageUrlItem] = []
|
url_images: list[ImageUrlItem] = []
|
||||||
if has_images_json:
|
if has_images_json and images_json:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(images_json)
|
parsed = json.loads(images_json)
|
||||||
if isinstance(parsed, list):
|
if isinstance(parsed, list):
|
||||||
|
|
@ -513,8 +826,8 @@ async def upload_images_blob(
|
||||||
detail=f"images_json 파싱 오류: {str(e)}",
|
detail=f"images_json 파싱 오류: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. 유효한 파일만 필터링
|
# 유효한 파일만 필터링 및 파일 내용 미리 읽기
|
||||||
valid_files: list[UploadFile] = []
|
valid_files_data: list[tuple[str, str, bytes]] = [] # (original_name, ext, content)
|
||||||
skipped_files: list[str] = []
|
skipped_files: list[str] = []
|
||||||
if has_files and files:
|
if has_files and files:
|
||||||
for f in files:
|
for f in files:
|
||||||
|
|
@ -523,20 +836,84 @@ async def upload_images_blob(
|
||||||
is_real_file = f.filename and f.filename != "filename"
|
is_real_file = f.filename and f.filename != "filename"
|
||||||
|
|
||||||
if f and is_real_file and is_valid_ext and is_not_empty:
|
if f and is_real_file and is_valid_ext and is_not_empty:
|
||||||
valid_files.append(f)
|
# 파일 내용을 미리 읽어둠
|
||||||
|
content = await f.read()
|
||||||
|
ext = _get_file_extension(f.filename) # type: ignore[arg-type]
|
||||||
|
valid_files_data.append((f.filename or "image", ext, content))
|
||||||
else:
|
else:
|
||||||
skipped_files.append(f.filename or "unknown")
|
skipped_files.append(f.filename or "unknown")
|
||||||
|
|
||||||
if not url_images and not valid_files:
|
if not url_images and not valid_files_data:
|
||||||
|
detail = (
|
||||||
|
f"유효한 이미지가 없습니다. "
|
||||||
|
f"지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. "
|
||||||
|
f"건너뛴 파일: {skipped_files}"
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"유효한 이미지가 없습니다. 지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. 건너뛴 파일: {skipped_files}",
|
detail=detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stage1_time = time.perf_counter()
|
||||||
|
logger.info(
|
||||||
|
f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
|
||||||
|
f"files: {len(valid_files_data)}, "
|
||||||
|
f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== Stage 2: Azure Blob 업로드 (세션 없음) ==========
|
||||||
|
# 업로드 결과를 저장할 리스트 (나중에 DB에 저장)
|
||||||
|
blob_upload_results: list[tuple[str, str]] = [] # (img_name, blob_url)
|
||||||
|
img_order = len(url_images) # URL 이미지 다음 순서부터 시작
|
||||||
|
|
||||||
|
if valid_files_data:
|
||||||
|
uploader = AzureBlobUploader(user_uuid=current_user.user_uuid, task_id=task_id)
|
||||||
|
total_files = len(valid_files_data)
|
||||||
|
|
||||||
|
for idx, (original_name, ext, file_content) in enumerate(valid_files_data):
|
||||||
|
name_without_ext = (
|
||||||
|
original_name.rsplit(".", 1)[0]
|
||||||
|
if "." in original_name
|
||||||
|
else original_name
|
||||||
|
)
|
||||||
|
filename = f"{name_without_ext}_{img_order:03d}{ext}"
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[upload_images_blob] Uploading file {idx + 1}/{total_files}: "
|
||||||
|
f"{filename} ({len(file_content)} bytes)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Azure Blob Storage에 직접 업로드
|
||||||
|
upload_success = await uploader.upload_image_bytes(file_content, filename)
|
||||||
|
|
||||||
|
if upload_success:
|
||||||
|
blob_url = uploader.public_url
|
||||||
|
blob_upload_results.append((original_name, blob_url))
|
||||||
|
img_order += 1
|
||||||
|
logger.debug(
|
||||||
|
f"[upload_images_blob] File {idx + 1}/{total_files} SUCCESS"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
skipped_files.append(filename)
|
||||||
|
logger.warning(
|
||||||
|
f"[upload_images_blob] File {idx + 1}/{total_files} FAILED"
|
||||||
|
)
|
||||||
|
|
||||||
|
stage2_time = time.perf_counter()
|
||||||
|
logger.info(
|
||||||
|
f"[upload_images_blob] Stage 2 done - blob uploads: "
|
||||||
|
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
|
||||||
|
f"elapsed: {(stage2_time - stage1_time) * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
|
||||||
|
logger.info("[upload_images_blob] Stage 3 starting - DB save...")
|
||||||
result_images: list[ImageUploadResultItem] = []
|
result_images: list[ImageUploadResultItem] = []
|
||||||
img_order = 0
|
img_order = 0
|
||||||
|
|
||||||
# 1. URL 이미지 저장
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
# URL 이미지 저장
|
||||||
for url_item in url_images:
|
for url_item in url_images:
|
||||||
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
|
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
|
||||||
|
|
||||||
|
|
@ -560,30 +937,8 @@ async def upload_images_blob(
|
||||||
)
|
)
|
||||||
img_order += 1
|
img_order += 1
|
||||||
|
|
||||||
# 2. 바이너리 파일을 Azure Blob Storage에 직접 업로드 (media 저장 없음)
|
# Blob 업로드 결과 저장
|
||||||
if valid_files:
|
for img_name, blob_url in blob_upload_results:
|
||||||
uploader = AzureBlobUploader(task_id=task_id)
|
|
||||||
|
|
||||||
for file in valid_files:
|
|
||||||
original_name = file.filename or "image"
|
|
||||||
ext = _get_file_extension(file.filename) # type: ignore[arg-type]
|
|
||||||
name_without_ext = (
|
|
||||||
original_name.rsplit(".", 1)[0]
|
|
||||||
if "." in original_name
|
|
||||||
else original_name
|
|
||||||
)
|
|
||||||
filename = f"{name_without_ext}_{img_order:03d}{ext}"
|
|
||||||
|
|
||||||
# 파일 내용 읽기
|
|
||||||
file_content = await file.read()
|
|
||||||
|
|
||||||
# Azure Blob Storage에 직접 업로드
|
|
||||||
upload_success = await uploader.upload_image_bytes(file_content, filename)
|
|
||||||
|
|
||||||
if upload_success:
|
|
||||||
blob_url = uploader.public_url
|
|
||||||
img_name = file.filename or filename
|
|
||||||
|
|
||||||
image = Image(
|
image = Image(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
img_name=img_name,
|
img_name=img_name,
|
||||||
|
|
@ -603,17 +958,48 @@ async def upload_images_blob(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
img_order += 1
|
img_order += 1
|
||||||
else:
|
|
||||||
skipped_files.append(filename)
|
await session.commit()
|
||||||
|
stage3_time = time.perf_counter()
|
||||||
|
logger.info(
|
||||||
|
f"[upload_images_blob] Stage 3 done - "
|
||||||
|
f"saved: {len(result_images)}, "
|
||||||
|
f"elapsed: {(stage3_time - stage2_time) * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}")
|
||||||
|
logger.exception("[upload_images_blob] DB 상세 오류:")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[upload_images_blob] Stage 3 EXCEPTION - "
|
||||||
|
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
logger.exception("[upload_images_blob] Stage 3 상세 오류:")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="이미지 업로드 중 오류가 발생했습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
saved_count = len(result_images)
|
saved_count = len(result_images)
|
||||||
await session.commit()
|
image_urls = [img.img_url for img in result_images]
|
||||||
|
|
||||||
|
total_time = time.perf_counter() - request_start
|
||||||
|
logger.info(
|
||||||
|
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||||
|
f"total: {saved_count}, total_time: {total_time * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
return ImageUploadResponse(
|
return ImageUploadResponse(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
total_count=len(result_images),
|
total_count=len(result_images),
|
||||||
url_count=len(url_images),
|
url_count=len(url_images),
|
||||||
file_count=len(valid_files) - len(skipped_files),
|
file_count=len(blob_upload_results),
|
||||||
saved_count=saved_count,
|
saved_count=saved_count,
|
||||||
images=result_images,
|
images=result_images,
|
||||||
|
image_urls=image_urls,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ Home 모듈 SQLAlchemy 모델 정의
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional, Any
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Index, Integer, String, Text, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, JSON, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
@ -17,6 +17,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -31,11 +32,12 @@ class Project(Base):
|
||||||
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:
|
||||||
|
owner: 프로젝트 소유자 (User, 1:N 관계)
|
||||||
lyrics: 생성된 가사 목록
|
lyrics: 생성된 가사 목록
|
||||||
songs: 생성된 노래 목록
|
songs: 생성된 노래 목록
|
||||||
videos: 최종 영상 결과 목록
|
videos: 최종 영상 결과 목록
|
||||||
|
|
@ -46,6 +48,8 @@ class Project(Base):
|
||||||
Index("idx_project_task_id", "task_id"),
|
Index("idx_project_task_id", "task_id"),
|
||||||
Index("idx_project_store_name", "store_name"),
|
Index("idx_project_store_name", "store_name"),
|
||||||
Index("idx_project_region", "region"),
|
Index("idx_project_region", "region"),
|
||||||
|
Index("idx_project_user_uuid", "user_uuid"),
|
||||||
|
Index("idx_project_is_deleted", "is_deleted"),
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
"mysql_charset": "utf8mb4",
|
"mysql_charset": "utf8mb4",
|
||||||
|
|
@ -64,21 +68,37 @@ class Project(Base):
|
||||||
store_name: Mapped[str] = mapped_column(
|
store_name: Mapped[str] = mapped_column(
|
||||||
String(255),
|
String(255),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
|
||||||
comment="가게명",
|
comment="가게명",
|
||||||
)
|
)
|
||||||
|
|
||||||
region: Mapped[str] = mapped_column(
|
region: Mapped[str] = mapped_column(
|
||||||
String(100),
|
String(100),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
|
||||||
comment="지역명 (예: 군산)",
|
comment="지역명 (예: 군산)",
|
||||||
)
|
)
|
||||||
|
|
||||||
task_id: Mapped[str] = mapped_column(
|
task_id: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="프로젝트 작업 고유 식별자 (UUID)",
|
unique=True,
|
||||||
|
comment="프로젝트 작업 고유 식별자 (UUID7)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# User 1:N 관계 (한 사용자가 여러 프로젝트를 소유)
|
||||||
|
# ==========================================================================
|
||||||
|
user_uuid: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
ForeignKey("user.user_uuid", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
comment="프로젝트 소유자 (User.user_uuid 외래키)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 소유자 관계 설정 (User.projects와 양방향 연결)
|
||||||
|
owner: Mapped[Optional["User"]] = relationship(
|
||||||
|
"User",
|
||||||
|
back_populates="projects",
|
||||||
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
|
|
||||||
detail_region_info: Mapped[Optional[str]] = mapped_column(
|
detail_region_info: Mapped[Optional[str]] = mapped_column(
|
||||||
|
|
@ -87,6 +107,12 @@ class Project(Base):
|
||||||
comment="상세 지역 정보",
|
comment="상세 지역 정보",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
marketing_intelligence: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="마케팅 인텔리전스 결과 정보 저장",
|
||||||
|
)
|
||||||
|
|
||||||
language: Mapped[str] = mapped_column(
|
language: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
@ -94,6 +120,13 @@ class Project(Base):
|
||||||
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
comment="소프트 삭제 여부 (True: 삭제됨)",
|
||||||
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
@ -147,7 +180,7 @@ class Image(Base):
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
|
task_id: 이미지 업로드 작업 고유 식별자 (UUID7)
|
||||||
img_name: 이미지명
|
img_name: 이미지명
|
||||||
img_url: 이미지 URL (S3, CDN 등의 경로)
|
img_url: 이미지 URL (S3, CDN 등의 경로)
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
|
|
@ -155,6 +188,8 @@ class Image(Base):
|
||||||
|
|
||||||
__tablename__ = "image"
|
__tablename__ = "image"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
Index("idx_image_task_id", "task_id"),
|
||||||
|
Index("idx_image_is_deleted", "is_deleted"),
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
"mysql_charset": "utf8mb4",
|
"mysql_charset": "utf8mb4",
|
||||||
|
|
@ -173,7 +208,7 @@ class Image(Base):
|
||||||
task_id: Mapped[str] = mapped_column(
|
task_id: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="이미지 업로드 작업 고유 식별자 (UUID)",
|
comment="이미지 업로드 작업 고유 식별자 (UUID7)",
|
||||||
)
|
)
|
||||||
|
|
||||||
img_name: Mapped[str] = mapped_column(
|
img_name: Mapped[str] = mapped_column(
|
||||||
|
|
@ -195,6 +230,76 @@ class Image(Base):
|
||||||
comment="이미지 순서",
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,7 @@
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from app.utils.prompts.schemas import MarketingPromptOutput
|
||||||
|
|
||||||
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):
|
class CrawlingRequest(BaseModel):
|
||||||
"""크롤링 요청 스키마"""
|
"""크롤링 요청 스키마"""
|
||||||
|
|
@ -122,6 +16,61 @@ class CrawlingRequest(BaseModel):
|
||||||
|
|
||||||
url: str = Field(..., description="네이버 지도 장소 URL")
|
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):
|
class ProcessedInfo(BaseModel):
|
||||||
"""가공된 장소 정보 스키마"""
|
"""가공된 장소 정보 스키마"""
|
||||||
|
|
@ -131,25 +80,168 @@ class ProcessedInfo(BaseModel):
|
||||||
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
||||||
|
|
||||||
|
|
||||||
class MarketingAnalysis(BaseModel):
|
# class MarketingAnalysisDetail(BaseModel):
|
||||||
"""마케팅 분석 결과 스키마"""
|
# detail_title : str = Field(..., description="디테일 카테고리 이름")
|
||||||
|
# detail_description : str = Field(..., description="해당 항목 설명")
|
||||||
|
|
||||||
report: str = Field(..., description="마케팅 분석 리포트")
|
# class MarketingAnalysisReport(BaseModel):
|
||||||
tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
|
# """마케팅 분석 리포트 스키마"""
|
||||||
facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
# 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):
|
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_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
||||||
image_count: int = Field(..., description="이미지 개수")
|
image_count: int = Field(..., description="이미지 개수")
|
||||||
processed_info: Optional[ProcessedInfo] = Field(
|
processed_info: Optional[ProcessedInfo] = Field(
|
||||||
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
||||||
)
|
)
|
||||||
marketing_analysis: Optional[MarketingAnalysis] = Field(
|
marketing_analysis: Optional[MarketingPromptOutput] = Field(
|
||||||
None, description="마케팅 분석 결과 (report, tags, facilities)"
|
None, description="마케팅 분석 결과 . 실패 시 null"
|
||||||
)
|
)
|
||||||
|
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
|
|
@ -173,29 +265,6 @@ class ImageUrlItem(BaseModel):
|
||||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadRequest(BaseModel):
|
|
||||||
"""이미지 업로드 요청 스키마 (JSON body 부분)
|
|
||||||
|
|
||||||
URL 이미지 목록을 전달합니다.
|
|
||||||
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"images": [
|
|
||||||
{"url": "https://example.com/images/image_001.jpg"},
|
|
||||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
images: Optional[list[ImageUrlItem]] = Field(
|
|
||||||
None, description="외부 이미지 URL 목록"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadResultItem(BaseModel):
|
class ImageUploadResultItem(BaseModel):
|
||||||
"""업로드된 이미지 결과 아이템"""
|
"""업로드된 이미지 결과 아이템"""
|
||||||
|
|
||||||
|
|
@ -211,9 +280,50 @@ class ImageUploadResultItem(BaseModel):
|
||||||
class ImageUploadResponse(BaseModel):
|
class ImageUploadResponse(BaseModel):
|
||||||
"""이미지 업로드 응답 스키마"""
|
"""이미지 업로드 응답 스키마"""
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
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="총 업로드된 이미지 개수")
|
total_count: int = Field(..., description="총 업로드된 이미지 개수")
|
||||||
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
|
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
|
||||||
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
|
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
|
||||||
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
|
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
|
||||||
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
|
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
|
||||||
|
image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""
|
||||||
|
네이버 지역 검색 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()
|
||||||
|
|
@ -2,6 +2,10 @@ import pytest
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.database.session import AsyncSessionLocal, engine
|
from app.database.session import AsyncSessionLocal, engine
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = get_logger("test_db")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -27,4 +31,4 @@ async def test_database_version():
|
||||||
result = await session.execute(text("SELECT VERSION()"))
|
result = await session.execute(text("SELECT VERSION()"))
|
||||||
version = result.scalar()
|
version = result.scalar()
|
||||||
assert version is not None
|
assert version is not None
|
||||||
print(f"MySQL Version: {version}")
|
logger.info(f"MySQL Version: {version}")
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ from fastapi import UploadFile
|
||||||
|
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
|
|
||||||
MEDIA_ROOT = Path("media")
|
|
||||||
|
|
||||||
|
|
||||||
async def save_upload_file(file: UploadFile, save_path: Path) -> None:
|
async def save_upload_file(file: UploadFile, save_path: Path) -> None:
|
||||||
"""업로드 파일을 지정된 경로에 저장"""
|
"""업로드 파일을 지정된 경로에 저장"""
|
||||||
|
|
@ -24,6 +22,7 @@ async def save_upload_file(file: UploadFile, save_path: Path) -> None:
|
||||||
|
|
||||||
async def upload_image_to_blob(
|
async def upload_image_to_blob(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
user_uuid: str,
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
filename: str,
|
filename: str,
|
||||||
save_dir: Path,
|
save_dir: Path,
|
||||||
|
|
@ -33,6 +32,7 @@ async def upload_image_to_blob(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: 작업 고유 식별자
|
task_id: 작업 고유 식별자
|
||||||
|
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
||||||
file: 업로드할 파일 객체
|
file: 업로드할 파일 객체
|
||||||
filename: 저장될 파일명
|
filename: 저장될 파일명
|
||||||
save_dir: media 저장 디렉토리 경로
|
save_dir: media 저장 디렉토리 경로
|
||||||
|
|
@ -48,7 +48,7 @@ async def upload_image_to_blob(
|
||||||
await save_upload_file(file, save_path)
|
await save_upload_file(file, save_path)
|
||||||
|
|
||||||
# 2. Azure Blob Storage에 업로드
|
# 2. Azure Blob Storage에 업로드
|
||||||
uploader = AzureBlobUploader(task_id=task_id)
|
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
|
||||||
upload_success = await uploader.upload_image(file_path=str(save_path))
|
upload_success = await uploader.upload_image(file_path=str(save_path))
|
||||||
|
|
||||||
if upload_success:
|
if upload_success:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Lyric API v1 라우터 모듈
|
||||||
|
"""
|
||||||
|
|
@ -8,11 +8,11 @@ Lyric API Router
|
||||||
- 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 /lyrics: 가사 목록 조회 (페이지네이션)
|
- GET /lyric/list: 가사 목록 조회 (페이지네이션)
|
||||||
|
|
||||||
사용 예시:
|
사용 예시:
|
||||||
from app.lyric.api.routers.v1.lyric import router
|
from app.lyric.api.routers.v1.lyric import router
|
||||||
app.include_router(router, prefix="/api/v1")
|
app.include_router(router)
|
||||||
|
|
||||||
다른 서비스에서 재사용:
|
다른 서비스에서 재사용:
|
||||||
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
|
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
|
||||||
|
|
@ -25,14 +25,14 @@ Lyric API Router
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
from app.home.models import Project
|
from app.home.models import Project, MarketingIntel
|
||||||
|
from app.user.dependencies.auth import get_current_user
|
||||||
|
from app.user.models import User
|
||||||
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,
|
||||||
|
|
@ -41,11 +41,18 @@ from app.lyric.schemas.lyric import (
|
||||||
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
|
||||||
|
# 로거 설정
|
||||||
|
logger = get_logger("lyric")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/lyric", tags=["Lyric"])
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -76,12 +83,17 @@ async def get_lyric_status_by_task_id(
|
||||||
if status_info.status == "completed":
|
if status_info.status == "completed":
|
||||||
# 완료 처리
|
# 완료 처리
|
||||||
"""
|
"""
|
||||||
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
|
logger.info(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
|
||||||
result = await session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
result = await session.execute(
|
||||||
|
select(Lyric)
|
||||||
|
.where(Lyric.task_id == task_id)
|
||||||
|
.order_by(Lyric.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
lyric = result.scalar_one_or_none()
|
lyric = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not lyric:
|
if not lyric:
|
||||||
print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
|
logger.warning(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||||
|
|
@ -93,7 +105,7 @@ async def get_lyric_status_by_task_id(
|
||||||
"failed": "가사 생성에 실패했습니다.",
|
"failed": "가사 생성에 실패했습니다.",
|
||||||
}
|
}
|
||||||
|
|
||||||
print(
|
logger.info(
|
||||||
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
|
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
|
||||||
)
|
)
|
||||||
return LyricStatusResponse(
|
return LyricStatusResponse(
|
||||||
|
|
@ -124,24 +136,28 @@ async def get_lyric_by_task_id(
|
||||||
|
|
||||||
lyric = await get_lyric_by_task_id(session, task_id)
|
lyric = await get_lyric_by_task_id(session, task_id)
|
||||||
"""
|
"""
|
||||||
print(f"[get_lyric_by_task_id] START - task_id: {task_id}")
|
logger.info(f"[get_lyric_by_task_id] START - task_id: {task_id}")
|
||||||
result = await session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
result = await session.execute(
|
||||||
|
select(Lyric)
|
||||||
|
.where(Lyric.task_id == task_id)
|
||||||
|
.order_by(Lyric.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
lyric = result.scalar_one_or_none()
|
lyric = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not lyric:
|
if not lyric:
|
||||||
print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
|
logger.warning(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
|
logger.info(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
|
||||||
return LyricDetailResponse(
|
return LyricDetailResponse(
|
||||||
id=lyric.id,
|
id=lyric.id,
|
||||||
task_id=lyric.task_id,
|
task_id=lyric.task_id,
|
||||||
project_id=lyric.project_id,
|
project_id=lyric.project_id,
|
||||||
status=lyric.status,
|
status=lyric.status,
|
||||||
lyric_prompt=lyric.lyric_prompt,
|
|
||||||
lyric_result=lyric.lyric_result,
|
lyric_result=lyric.lyric_result,
|
||||||
created_at=lyric.created_at,
|
created_at=lyric.created_at,
|
||||||
)
|
)
|
||||||
|
|
@ -157,174 +173,225 @@ async def get_lyric_by_task_id(
|
||||||
summary="가사 생성",
|
summary="가사 생성",
|
||||||
description="""
|
description="""
|
||||||
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
||||||
|
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||||
|
|
||||||
## 요청 필드
|
## 요청 필드
|
||||||
|
- **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, English, Chinese, Japanese, Thai, Vietnamese)
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **success**: 생성 성공 여부
|
- **success**: 요청 접수 성공 여부
|
||||||
- **task_id**: 작업 고유 식별자
|
- **task_id**: 작업 고유 식별자
|
||||||
- **lyric**: 생성된 가사 (성공 시)
|
- **lyric**: null (백그라운드 처리 중)
|
||||||
- **language**: 가사 언어
|
- **language**: 가사 언어
|
||||||
- **error_message**: 에러 메시지 (실패 시)
|
- **error_message**: 에러 메시지 (요청 접수 실패 시)
|
||||||
|
|
||||||
## 실패 조건
|
## 상태 확인
|
||||||
- ChatGPT API 오류
|
- GET /lyric/status/{task_id} 로 처리 상태 확인
|
||||||
- ChatGPT 거부 응답 (I'm sorry, I cannot 등)
|
- GET /lyric/{task_id} 로 생성된 가사 조회
|
||||||
- 응답에 ERROR: 포함
|
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시 (cURL)
|
||||||
```
|
```bash
|
||||||
POST /lyric/generate
|
curl -X POST "http://localhost:8000/lyric/generate" \\
|
||||||
{
|
-H "Authorization: Bearer {access_token}" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean"
|
"language": "Korean"
|
||||||
}
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 응답 예시 (성공)
|
## 응답 예시
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"lyric": "인스타 감성의 스테이 머뭄...",
|
|
||||||
"language": "Korean",
|
|
||||||
"error_message": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 응답 예시 (실패)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"lyric": null,
|
"lyric": null,
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"error_message": "I'm sorry, I can't comply with that request."
|
"error_message": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
""",
|
""",
|
||||||
response_model=GenerateLyricResponse,
|
response_model=GenerateLyricResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "가사 생성 성공 또는 실패 (success 필드로 구분)"},
|
200: {"description": "가사 생성 요청 접수 성공"},
|
||||||
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||||
500: {"description": "서버 내부 오류"},
|
500: {"description": "서버 내부 오류"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def generate_lyric(
|
async def generate_lyric(
|
||||||
request_body: GenerateLyricRequest,
|
request_body: GenerateLyricRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> GenerateLyricResponse:
|
) -> GenerateLyricResponse:
|
||||||
"""고객 정보를 기반으로 가사를 생성합니다."""
|
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
||||||
task_id = await generate_task_id(session=session, table_name=Project)
|
import time
|
||||||
print(
|
|
||||||
f"[generate_lyric] START - task_id: {task_id}, customer_name: {request_body.customer_name}, region: {request_body.region}"
|
request_start = time.perf_counter()
|
||||||
|
task_id = request_body.task_id
|
||||||
|
|
||||||
|
|
||||||
|
logger.info(f"[generate_lyric] ========== START ==========")
|
||||||
|
logger.info(
|
||||||
|
f"[generate_lyric] task_id: {task_id}, "
|
||||||
|
f"customer_name: {request_body.customer_name}, "
|
||||||
|
f"region: {request_body.region}"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. ChatGPT 서비스 초기화 및 프롬프트 생성
|
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
|
||||||
service = ChatgptService(
|
step1_start = time.perf_counter()
|
||||||
customer_name=request_body.customer_name,
|
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
||||||
region=request_body.region,
|
|
||||||
detail_region_info=request_body.detail_region_info or "",
|
|
||||||
language=request_body.language,
|
|
||||||
)
|
|
||||||
prompt = service.build_lyrics_prompt()
|
|
||||||
|
|
||||||
# 2. Project 테이블에 데이터 저장
|
# service = ChatgptService(
|
||||||
|
# customer_name=request_body.customer_name,
|
||||||
|
# region=request_body.region,
|
||||||
|
# detail_region_info=request_body.detail_region_info or "",
|
||||||
|
# language=request_body.language,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# prompt = service.build_lyrics_prompt()
|
||||||
|
# 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨.
|
||||||
|
# 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성
|
||||||
|
|
||||||
|
promotional_expressions = {
|
||||||
|
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
|
||||||
|
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
|
||||||
|
"Chinese" : "网红打卡, 治愈系, 旅行, 度假, 拍照圣地",
|
||||||
|
"Japanese" : "インスタ映え, 写真のような一日, 癒し, 旅行, 絶景",
|
||||||
|
"Thai" : "ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย",
|
||||||
|
"Vietnamese" : "check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp"
|
||||||
|
}# HARD CODED, 어디에 정리하지? 아직 정리되지 않음
|
||||||
|
|
||||||
|
timing_rules = {
|
||||||
|
"60s" : """
|
||||||
|
8–12 lines
|
||||||
|
Full verse flow, immersive mood
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id))
|
||||||
|
marketing_intel = marketing_intel_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
lyric_input_data = {
|
||||||
|
"customer_name" : request_body.customer_name,
|
||||||
|
"region" : request_body.region,
|
||||||
|
"detail_region_info" : request_body.detail_region_info or "",
|
||||||
|
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
||||||
|
"language" : request_body.language,
|
||||||
|
"promotional_expression_example" : promotional_expressions[request_body.language],
|
||||||
|
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
||||||
|
}
|
||||||
|
|
||||||
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
|
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||||
|
|
||||||
|
# ========== Step 2: Project 조회 또는 생성 ==========
|
||||||
|
step2_start = time.perf_counter()
|
||||||
|
logger.debug(f"[generate_lyric] Step 2: Project 조회 또는 생성...")
|
||||||
|
|
||||||
|
# 기존 Project가 있는지 확인 (재생성 시 재사용)
|
||||||
|
existing_project_result = await session.execute(
|
||||||
|
select(Project).where(Project.task_id == task_id).limit(1)
|
||||||
|
)
|
||||||
|
project = existing_project_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if project:
|
||||||
|
# 기존 Project 재사용 (재생성 케이스)
|
||||||
|
logger.info(f"[generate_lyric] 기존 Project 재사용 - project_id: {project.id}, task_id: {task_id}")
|
||||||
|
else:
|
||||||
|
# 새 Project 생성 (최초 생성 케이스)
|
||||||
project = Project(
|
project = Project(
|
||||||
store_name=request_body.customer_name,
|
store_name=request_body.customer_name,
|
||||||
region=request_body.region,
|
region=request_body.region,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
detail_region_info=request_body.detail_region_info,
|
detail_region_info=request_body.detail_region_info,
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
|
user_uuid=current_user.user_uuid,
|
||||||
|
marketing_intelligence = request_body.m_id
|
||||||
)
|
)
|
||||||
session.add(project)
|
session.add(project)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(project) # commit 후 project.id 동기화
|
await session.refresh(project)
|
||||||
print(
|
logger.info(f"[generate_lyric] 새 Project 생성 - project_id: {project.id}, task_id: {task_id}")
|
||||||
f"[generate_lyric] Project saved - project_id: {project.id}, task_id: {task_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Lyric 테이블에 데이터 저장 (status: processing)
|
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 테이블에 데이터 저장 ==========
|
||||||
|
step3_start = time.perf_counter()
|
||||||
|
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
||||||
|
|
||||||
|
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
||||||
lyric = Lyric(
|
lyric = Lyric(
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
status="processing",
|
status="processing",
|
||||||
lyric_prompt=prompt,
|
lyric_prompt=estimated_prompt,
|
||||||
lyric_result=None,
|
lyric_result=None,
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
)
|
)
|
||||||
session.add(lyric)
|
session.add(lyric)
|
||||||
await (
|
|
||||||
session.commit()
|
|
||||||
) # processing 상태를 확실히 저장 (다른 트랜잭션에서 조회 가능)
|
|
||||||
await session.refresh(lyric) # commit 후 객체 상태 동기화
|
|
||||||
print(
|
|
||||||
f"[generate_lyric] Lyric saved (processing) - lyric_id: {lyric.id}, task_id: {task_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. ChatGPT를 통해 가사 생성
|
|
||||||
print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}")
|
|
||||||
result = await service.generate(prompt=prompt)
|
|
||||||
print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}")
|
|
||||||
|
|
||||||
# 5. 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답)
|
|
||||||
failure_patterns = [
|
|
||||||
"ERROR:",
|
|
||||||
"I'm sorry",
|
|
||||||
"I cannot",
|
|
||||||
"I can't",
|
|
||||||
"I apologize",
|
|
||||||
"I'm unable",
|
|
||||||
"I am unable",
|
|
||||||
"I'm not able",
|
|
||||||
"I am not able",
|
|
||||||
]
|
|
||||||
is_failure = any(
|
|
||||||
pattern.lower() in result.lower() for pattern in failure_patterns
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_failure:
|
|
||||||
print(f"[generate_lyric] FAILED - task_id: {task_id}, error: {result}")
|
|
||||||
lyric.status = "failed"
|
|
||||||
lyric.lyric_result = result
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await session.refresh(lyric)
|
||||||
|
|
||||||
return GenerateLyricResponse(
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||||
success=False,
|
logger.debug(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)")
|
||||||
|
|
||||||
|
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
||||||
|
step4_start = time.perf_counter()
|
||||||
|
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
||||||
|
|
||||||
|
background_tasks.add_task(
|
||||||
|
generate_lyric_background,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
lyric=None,
|
prompt=lyric_prompt,
|
||||||
language=request_body.language,
|
lyric_input_data=lyric_input_data,
|
||||||
error_message=result,
|
lyric_id=lyric.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6. 성공 시 Lyric 테이블 업데이트 (status: completed)
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||||
lyric.status = "completed"
|
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
|
||||||
lyric.lyric_result = result
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
print(f"[generate_lyric] SUCCESS - task_id: {task_id}")
|
# ========== 완료 ==========
|
||||||
|
total_elapsed = (time.perf_counter() - request_start) * 1000
|
||||||
|
logger.info(f"[generate_lyric] ========== COMPLETE ==========")
|
||||||
|
logger.info(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
|
||||||
|
logger.debug(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms")
|
||||||
|
logger.debug(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms")
|
||||||
|
logger.debug(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms")
|
||||||
|
logger.debug(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms")
|
||||||
|
logger.debug(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)")
|
||||||
|
|
||||||
|
# 5. 즉시 응답 반환
|
||||||
return GenerateLyricResponse(
|
return GenerateLyricResponse(
|
||||||
success=True,
|
success=True,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
lyric=result,
|
lyric=None,
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
error_message=None,
|
error_message=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {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()
|
await session.rollback()
|
||||||
return GenerateLyricResponse(
|
return GenerateLyricResponse(
|
||||||
success=False,
|
success=False,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
lyric=None,
|
lyric=None,
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
error_message=str(e),
|
error_message=''.join(tb.format_exception(None, e, e.__traceback__)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -334,24 +401,30 @@ async def generate_lyric(
|
||||||
description="""
|
description="""
|
||||||
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
|
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||||
|
|
||||||
## 상태 값
|
## 상태 값
|
||||||
- **processing**: 가사 생성 중
|
- **processing**: 가사 생성 중
|
||||||
- **completed**: 가사 생성 완료
|
- **completed**: 가사 생성 완료
|
||||||
- **failed**: 가사 생성 실패
|
- **failed**: 가사 생성 실패
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시 (cURL)
|
||||||
```
|
```bash
|
||||||
GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
|
curl -X GET "http://localhost:8000/lyric/status/019123ab-cdef-7890-abcd-ef1234567890" \\
|
||||||
|
-H "Authorization: Bearer {access_token}"
|
||||||
```
|
```
|
||||||
""",
|
""",
|
||||||
response_model=LyricStatusResponse,
|
response_model=LyricStatusResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "상태 조회 성공"},
|
200: {"description": "상태 조회 성공"},
|
||||||
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||||
404: {"description": "해당 task_id를 찾을 수 없음"},
|
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_lyric_status(
|
async def get_lyric_status(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> LyricStatusResponse:
|
) -> LyricStatusResponse:
|
||||||
"""task_id로 가사 생성 작업 상태를 조회합니다."""
|
"""task_id로 가사 생성 작업 상태를 조회합니다."""
|
||||||
|
|
@ -359,11 +432,14 @@ async def get_lyric_status(
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"s",
|
"/list",
|
||||||
summary="가사 목록 조회 (페이지네이션)",
|
summary="가사 목록 조회 (페이지네이션)",
|
||||||
description="""
|
description="""
|
||||||
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||||
|
|
||||||
## 파라미터
|
## 파라미터
|
||||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
||||||
- **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100)
|
- **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100)
|
||||||
|
|
@ -377,11 +453,19 @@ async def get_lyric_status(
|
||||||
- **has_next**: 다음 페이지 존재 여부
|
- **has_next**: 다음 페이지 존재 여부
|
||||||
- **has_prev**: 이전 페이지 존재 여부
|
- **has_prev**: 이전 페이지 존재 여부
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시 (cURL)
|
||||||
```
|
```bash
|
||||||
GET /lyrics # 기본 조회 (1페이지, 20개)
|
# 기본 조회 (1페이지, 20개)
|
||||||
GET /lyrics?page=2 # 2페이지 조회
|
curl -X GET "http://localhost:8000/lyric/list" \\
|
||||||
GET /lyrics?page=1&page_size=50 # 50개씩 조회
|
-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}"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
|
|
@ -391,11 +475,13 @@ GET /lyrics?page=1&page_size=50 # 50개씩 조회
|
||||||
response_model=PaginatedResponse[LyricListItem],
|
response_model=PaginatedResponse[LyricListItem],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "가사 목록 조회 성공"},
|
200: {"description": "가사 목록 조회 성공"},
|
||||||
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def list_lyrics(
|
async def list_lyrics(
|
||||||
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
|
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
|
||||||
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
|
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> PaginatedResponse[LyricListItem]:
|
) -> PaginatedResponse[LyricListItem]:
|
||||||
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
|
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
|
||||||
|
|
@ -417,6 +503,9 @@ async def list_lyrics(
|
||||||
description="""
|
description="""
|
||||||
task_id로 생성된 가사의 상세 정보를 조회합니다.
|
task_id로 생성된 가사의 상세 정보를 조회합니다.
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **id**: 가사 ID
|
- **id**: 가사 ID
|
||||||
- **task_id**: 작업 고유 식별자
|
- **task_id**: 작업 고유 식별자
|
||||||
|
|
@ -426,19 +515,22 @@ task_id로 생성된 가사의 상세 정보를 조회합니다.
|
||||||
- **lyric_result**: 생성된 가사 (완료 시)
|
- **lyric_result**: 생성된 가사 (완료 시)
|
||||||
- **created_at**: 생성 일시
|
- **created_at**: 생성 일시
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시 (cURL)
|
||||||
```
|
```bash
|
||||||
GET /lyric/019123ab-cdef-7890-abcd-ef1234567890
|
curl -X GET "http://localhost:8000/lyric/019123ab-cdef-7890-abcd-ef1234567890" \\
|
||||||
|
-H "Authorization: Bearer {access_token}"
|
||||||
```
|
```
|
||||||
""",
|
""",
|
||||||
response_model=LyricDetailResponse,
|
response_model=LyricDetailResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "가사 조회 성공"},
|
200: {"description": "가사 조회 성공"},
|
||||||
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||||
404: {"description": "해당 task_id를 찾을 수 없음"},
|
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_lyric_detail(
|
async def get_lyric_detail(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> LyricDetailResponse:
|
) -> LyricDetailResponse:
|
||||||
"""task_id로 생성된 가사를 조회합니다."""
|
"""task_id로 생성된 가사를 조회합니다."""
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database.session import get_session
|
|
||||||
|
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, 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
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ class Lyric(Base):
|
||||||
Attributes:
|
Attributes:
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
project_id: 연결된 Project의 id (외래키)
|
project_id: 연결된 Project의 id (외래키)
|
||||||
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
|
task_id: 가사 생성 작업의 고유 식별자 (UUID7 형식)
|
||||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||||
lyric_prompt: 가사 생성에 사용된 프롬프트
|
lyric_prompt: 가사 생성에 사용된 프롬프트
|
||||||
lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원)
|
lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원)
|
||||||
|
|
@ -37,6 +37,9 @@ class Lyric(Base):
|
||||||
|
|
||||||
__tablename__ = "lyric"
|
__tablename__ = "lyric"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
Index("idx_lyric_task_id", "task_id"),
|
||||||
|
Index("idx_lyric_project_id", "project_id"),
|
||||||
|
Index("idx_lyric_is_deleted", "is_deleted"),
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
"mysql_charset": "utf8mb4",
|
"mysql_charset": "utf8mb4",
|
||||||
|
|
@ -56,14 +59,13 @@ class Lyric(Base):
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("project.id", ondelete="CASCADE"),
|
ForeignKey("project.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
|
||||||
comment="연결된 Project의 id",
|
comment="연결된 Project의 id",
|
||||||
)
|
)
|
||||||
|
|
||||||
task_id: Mapped[str] = mapped_column(
|
task_id: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="가사 생성 작업 고유 식별자 (UUID)",
|
comment="가사 생성 작업 고유 식별자 (UUID7)",
|
||||||
)
|
)
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
|
|
@ -91,6 +93,13 @@ class Lyric(Base):
|
||||||
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
comment="소프트 삭제 여부 (True: 삭제됨)",
|
||||||
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ Lyric API Schemas
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class GenerateLyricRequest(BaseModel):
|
class GenerateLyricRequest(BaseModel):
|
||||||
|
|
@ -37,24 +37,31 @@ class GenerateLyricRequest(BaseModel):
|
||||||
|
|
||||||
Example Request:
|
Example Request:
|
||||||
{
|
{
|
||||||
"customer_name": "스테이 머뭄",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"region": "군산",
|
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
|
||||||
"language": "Korean"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = {
|
|
||||||
"json_schema_extra": {
|
|
||||||
"example": {
|
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
|
"m_id" : 1
|
||||||
}
|
}
|
||||||
}
|
"""
|
||||||
}
|
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
|
"customer_name": "스테이 머뭄",
|
||||||
|
"region": "군산",
|
||||||
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
|
"language": "Korean",
|
||||||
|
"m_id" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
task_id: str = Field(
|
||||||
|
..., description="작업 고유 식별자 (이미지 업로드 시 생성된 task_id)"
|
||||||
|
)
|
||||||
customer_name: str = Field(..., description="고객명/가게명")
|
customer_name: str = Field(..., description="고객명/가게명")
|
||||||
region: str = Field(..., description="지역명")
|
region: str = Field(..., description="지역명")
|
||||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||||
|
|
@ -62,6 +69,7 @@ class GenerateLyricRequest(BaseModel):
|
||||||
default="Korean",
|
default="Korean",
|
||||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
||||||
|
|
||||||
|
|
||||||
class GenerateLyricResponse(BaseModel):
|
class GenerateLyricResponse(BaseModel):
|
||||||
|
|
@ -76,26 +84,20 @@ class GenerateLyricResponse(BaseModel):
|
||||||
- ChatGPT API 오류
|
- ChatGPT API 오류
|
||||||
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등)
|
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등)
|
||||||
- 응답에 ERROR: 포함
|
- 응답에 ERROR: 포함
|
||||||
|
|
||||||
Example Response (Success):
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
|
||||||
"lyric": "인스타 감성의 스테이 머뭄...",
|
|
||||||
"language": "Korean",
|
|
||||||
"error_message": null
|
|
||||||
}
|
|
||||||
|
|
||||||
Example Response (Failure):
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
|
||||||
"lyric": null,
|
|
||||||
"language": "Korean",
|
|
||||||
"error_message": "I'm sorry, I can't comply with that request."
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"success": True,
|
||||||
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
|
"lyric": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||||
|
"language": "Korean",
|
||||||
|
"error_message": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
success: bool = Field(..., description="생성 성공 여부")
|
success: bool = Field(..., description="생성 성공 여부")
|
||||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
|
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
|
||||||
lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)")
|
lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)")
|
||||||
|
|
@ -110,14 +112,35 @@ class LyricStatusResponse(BaseModel):
|
||||||
GET /lyric/status/{task_id}
|
GET /lyric/status/{task_id}
|
||||||
Returns the current processing status of a lyric generation task.
|
Returns the current processing status of a lyric generation task.
|
||||||
|
|
||||||
Example Response:
|
Status Values:
|
||||||
{
|
- processing: 가사 생성 진행 중
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
- completed: 가사 생성 완료
|
||||||
"status": "completed",
|
- failed: ChatGPT API 오류 또는 생성 실패
|
||||||
"message": "가사 생성이 완료되었습니다."
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"summary": "성공",
|
||||||
|
"value": {
|
||||||
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
|
"status": "completed",
|
||||||
|
"message": "가사 생성이 완료되었습니다.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "실패",
|
||||||
|
"value": {
|
||||||
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
|
"status": "failed",
|
||||||
|
"message": "가사 생성에 실패했습니다.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||||
message: str = Field(..., description="상태 메시지")
|
message: str = Field(..., description="상태 메시지")
|
||||||
|
|
@ -130,24 +153,45 @@ class LyricDetailResponse(BaseModel):
|
||||||
GET /lyric/{task_id}
|
GET /lyric/{task_id}
|
||||||
Returns the generated lyric content for a specific task.
|
Returns the generated lyric content for a specific task.
|
||||||
|
|
||||||
Example Response:
|
Note:
|
||||||
|
- status가 "failed"인 경우 lyric_result에 에러 메시지가 저장됩니다.
|
||||||
|
- 에러 메시지 형식: "ChatGPT Error: {message}" 또는 "Error: {message}"
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"examples": [
|
||||||
{
|
{
|
||||||
|
"summary": "성공",
|
||||||
|
"value": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"project_id": 1,
|
"project_id": 1,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"lyric_prompt": "...",
|
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||||
"lyric_result": "생성된 가사...",
|
"created_at": "2024-01-15T12:00:00",
|
||||||
"created_at": "2024-01-01T12: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")
|
id: int = Field(..., description="가사 ID")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
project_id: int = Field(..., description="프로젝트 ID")
|
project_id: int = Field(..., description="프로젝트 ID")
|
||||||
status: str = Field(..., description="처리 상태")
|
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||||
lyric_prompt: str = Field(..., description="가사 생성 프롬프트")
|
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
|
||||||
lyric_result: Optional[str] = Field(None, description="생성된 가사")
|
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -158,6 +202,18 @@ class LyricListItem(BaseModel):
|
||||||
Used as individual items in paginated lyric list responses.
|
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")
|
id: int = Field(..., description="가사 ID")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
status: str = Field(..., description="처리 상태")
|
status: str = Field(..., description="처리 상태")
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ class SongFormData:
|
||||||
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-4o"
|
llm_model: str = "gpt-5-mini"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_form(cls, request: Request):
|
async def from_form(cls, request: Request):
|
||||||
|
|
@ -86,6 +86,6 @@ class SongFormData:
|
||||||
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-4o"),
|
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
||||||
prompts=form_data.get("prompts", ""),
|
prompts=form_data.get("prompts", ""),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,852 +0,0 @@
|
||||||
import random
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from fastapi import Request, status
|
|
||||||
from fastapi.exceptions import HTTPException
|
|
||||||
from sqlalchemy import Connection, text
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
|
|
||||||
from app.lyric.schemas.lyrics_schema import (
|
|
||||||
AttributeData,
|
|
||||||
PromptTemplateData,
|
|
||||||
SongFormData,
|
|
||||||
SongSampleData,
|
|
||||||
StoreData,
|
|
||||||
)
|
|
||||||
from app.utils.chatgpt_prompt import chatgpt_api
|
|
||||||
|
|
||||||
|
|
||||||
async def get_store_info(conn: Connection) -> List[StoreData]:
|
|
||||||
try:
|
|
||||||
query = """SELECT * FROM store_default_info;"""
|
|
||||||
result = await conn.execute(text(query))
|
|
||||||
|
|
||||||
all_store_info = [
|
|
||||||
StoreData(
|
|
||||||
id=row[0],
|
|
||||||
store_info=row[1],
|
|
||||||
store_name=row[2],
|
|
||||||
store_category=row[3],
|
|
||||||
store_region=row[4],
|
|
||||||
store_address=row[5],
|
|
||||||
store_phone_number=row[6],
|
|
||||||
created_at=row[7],
|
|
||||||
)
|
|
||||||
for row in result
|
|
||||||
]
|
|
||||||
|
|
||||||
result.close()
|
|
||||||
return all_store_info
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_attribute(conn: Connection) -> List[AttributeData]:
|
|
||||||
try:
|
|
||||||
query = """SELECT * FROM attribute;"""
|
|
||||||
result = await conn.execute(text(query))
|
|
||||||
|
|
||||||
all_attribute = [
|
|
||||||
AttributeData(
|
|
||||||
id=row[0],
|
|
||||||
attr_category=row[1],
|
|
||||||
attr_value=row[2],
|
|
||||||
created_at=row[3],
|
|
||||||
)
|
|
||||||
for row in result
|
|
||||||
]
|
|
||||||
|
|
||||||
result.close()
|
|
||||||
return all_attribute
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_attribute(conn: Connection) -> List[AttributeData]:
|
|
||||||
try:
|
|
||||||
query = """SELECT * FROM attribute;"""
|
|
||||||
result = await conn.execute(text(query))
|
|
||||||
|
|
||||||
all_attribute = [
|
|
||||||
AttributeData(
|
|
||||||
id=row[0],
|
|
||||||
attr_category=row[1],
|
|
||||||
attr_value=row[2],
|
|
||||||
created_at=row[3],
|
|
||||||
)
|
|
||||||
for row in result
|
|
||||||
]
|
|
||||||
|
|
||||||
result.close()
|
|
||||||
return all_attribute
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_sample_song(conn: Connection) -> List[SongSampleData]:
|
|
||||||
try:
|
|
||||||
query = """SELECT * FROM song_sample;"""
|
|
||||||
result = await conn.execute(text(query))
|
|
||||||
|
|
||||||
all_sample_song = [
|
|
||||||
SongSampleData(
|
|
||||||
id=row[0],
|
|
||||||
ai=row[1],
|
|
||||||
ai_model=row[2],
|
|
||||||
genre=row[3],
|
|
||||||
sample_song=row[4],
|
|
||||||
)
|
|
||||||
for row in result
|
|
||||||
]
|
|
||||||
|
|
||||||
result.close()
|
|
||||||
return all_sample_song
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]:
|
|
||||||
try:
|
|
||||||
query = """SELECT * FROM prompt_template;"""
|
|
||||||
result = await conn.execute(text(query))
|
|
||||||
|
|
||||||
all_prompt_template = [
|
|
||||||
PromptTemplateData(
|
|
||||||
id=row[0],
|
|
||||||
description=row[1],
|
|
||||||
prompt=row[2],
|
|
||||||
)
|
|
||||||
for row in result
|
|
||||||
]
|
|
||||||
|
|
||||||
result.close()
|
|
||||||
return all_prompt_template
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_song_result(conn: Connection) -> List[PromptTemplateData]:
|
|
||||||
try:
|
|
||||||
query = """SELECT * FROM prompt_template;"""
|
|
||||||
result = await conn.execute(text(query))
|
|
||||||
|
|
||||||
all_prompt_template = [
|
|
||||||
PromptTemplateData(
|
|
||||||
id=row[0],
|
|
||||||
description=row[1],
|
|
||||||
prompt=row[2],
|
|
||||||
)
|
|
||||||
for row in result
|
|
||||||
]
|
|
||||||
|
|
||||||
result.close()
|
|
||||||
return all_prompt_template
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def make_song_result(request: Request, conn: Connection):
|
|
||||||
try:
|
|
||||||
# 1. Form 데이터 파싱
|
|
||||||
form_data = await SongFormData.from_form(request)
|
|
||||||
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print(f"Store ID: {form_data.store_id}")
|
|
||||||
print(f"Lyrics IDs: {form_data.lyrics_ids}")
|
|
||||||
print(f"Prompt IDs: {form_data.prompts}")
|
|
||||||
print(f"{'=' * 60}\n")
|
|
||||||
|
|
||||||
# 2. Store 정보 조회
|
|
||||||
store_query = """
|
|
||||||
SELECT * FROM store_default_info WHERE id=:id;
|
|
||||||
"""
|
|
||||||
store_result = await conn.execute(text(store_query), {"id": form_data.store_id})
|
|
||||||
|
|
||||||
all_store_info = [
|
|
||||||
StoreData(
|
|
||||||
id=row[0],
|
|
||||||
store_info=row[1],
|
|
||||||
store_name=row[2],
|
|
||||||
store_category=row[3],
|
|
||||||
store_region=row[4],
|
|
||||||
store_address=row[5],
|
|
||||||
store_phone_number=row[6],
|
|
||||||
created_at=row[7],
|
|
||||||
)
|
|
||||||
for row in store_result
|
|
||||||
]
|
|
||||||
|
|
||||||
if not all_store_info:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Store not found: {form_data.store_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
store_info = all_store_info[0]
|
|
||||||
print(f"Store: {store_info.store_name}")
|
|
||||||
|
|
||||||
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
|
||||||
|
|
||||||
# 4. Sample Song 조회 및 결합
|
|
||||||
combined_sample_song = None
|
|
||||||
|
|
||||||
if form_data.lyrics_ids:
|
|
||||||
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
|
||||||
|
|
||||||
lyrics_query = """
|
|
||||||
SELECT sample_song FROM song_sample
|
|
||||||
WHERE id IN :ids
|
|
||||||
ORDER BY created_at;
|
|
||||||
"""
|
|
||||||
lyrics_result = await conn.execute(
|
|
||||||
text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)}
|
|
||||||
)
|
|
||||||
|
|
||||||
sample_songs = [
|
|
||||||
row.sample_song for row in lyrics_result.fetchall() if row.sample_song
|
|
||||||
]
|
|
||||||
|
|
||||||
if sample_songs:
|
|
||||||
combined_sample_song = "\n\n".join(
|
|
||||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
|
||||||
)
|
|
||||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
|
||||||
else:
|
|
||||||
print("샘플 가사가 비어있습니다")
|
|
||||||
else:
|
|
||||||
print("선택된 lyrics가 없습니다")
|
|
||||||
|
|
||||||
# 5. 템플릿 가져오기
|
|
||||||
if not form_data.prompts:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="프롬프트 ID가 필요합니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
print("템플릿 가져오기")
|
|
||||||
|
|
||||||
prompts_query = """
|
|
||||||
SELECT * FROM prompt_template WHERE id=:id;
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ✅ 수정: store_query → prompts_query
|
|
||||||
prompts_result = await conn.execute(
|
|
||||||
text(prompts_query), {"id": form_data.prompts}
|
|
||||||
)
|
|
||||||
|
|
||||||
prompts_info = [
|
|
||||||
PromptTemplateData(
|
|
||||||
id=row[0],
|
|
||||||
description=row[1],
|
|
||||||
prompt=row[2],
|
|
||||||
)
|
|
||||||
for row in prompts_result
|
|
||||||
]
|
|
||||||
|
|
||||||
if not prompts_info:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Prompt not found: {form_data.prompts}",
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = prompts_info[0]
|
|
||||||
print(f"Prompt Template: {prompt.prompt}")
|
|
||||||
|
|
||||||
# ✅ 6. 프롬프트 조합
|
|
||||||
updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format(
|
|
||||||
name=store_info.store_name or "",
|
|
||||||
address=store_info.store_address or "",
|
|
||||||
category=store_info.store_category or "",
|
|
||||||
description=store_info.store_info or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_prompt += f"""
|
|
||||||
|
|
||||||
다음은 참고해야 하는 샘플 가사 정보입니다.
|
|
||||||
|
|
||||||
샘플 가사를 참고하여 작곡을 해주세요.
|
|
||||||
|
|
||||||
{combined_sample_song}
|
|
||||||
"""
|
|
||||||
|
|
||||||
print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n")
|
|
||||||
|
|
||||||
# 7. 모델에게 요청
|
|
||||||
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
|
|
||||||
|
|
||||||
# 글자 수 계산
|
|
||||||
total_chars_with_space = len(generated_lyrics)
|
|
||||||
total_chars_without_space = len(
|
|
||||||
generated_lyrics.replace(" ", "")
|
|
||||||
.replace("\n", "")
|
|
||||||
.replace("\r", "")
|
|
||||||
.replace("\t", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
# final_lyrics 생성
|
|
||||||
final_lyrics = f"""속성 {form_data.attributes_str}
|
|
||||||
전체 글자 수 (공백 포함): {total_chars_with_space}자
|
|
||||||
전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}"""
|
|
||||||
|
|
||||||
print("=" * 40)
|
|
||||||
print("[translate:form_data.attributes_str:] ", form_data.attributes_str)
|
|
||||||
print("[translate:total_chars_with_space:] ", total_chars_with_space)
|
|
||||||
print("[translate:total_chars_without_space:] ", total_chars_without_space)
|
|
||||||
print("[translate:final_lyrics:]")
|
|
||||||
print(final_lyrics)
|
|
||||||
print("=" * 40)
|
|
||||||
|
|
||||||
# 8. DB 저장
|
|
||||||
insert_query = """
|
|
||||||
INSERT INTO song_results_all (
|
|
||||||
store_info, store_name, store_category, store_address, store_phone_number,
|
|
||||||
description, prompt, attr_category, attr_value,
|
|
||||||
ai, ai_model, genre,
|
|
||||||
sample_song, result_song, created_at
|
|
||||||
) VALUES (
|
|
||||||
:store_info, :store_name, :store_category, :store_address, :store_phone_number,
|
|
||||||
:description, :prompt, :attr_category, :attr_value,
|
|
||||||
:ai, :ai_model, :genre,
|
|
||||||
:sample_song, :result_song, NOW()
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ✅ attr_category, attr_value 추가
|
|
||||||
insert_params = {
|
|
||||||
"store_info": store_info.store_info or "",
|
|
||||||
"store_name": store_info.store_name,
|
|
||||||
"store_category": store_info.store_category or "",
|
|
||||||
"store_address": store_info.store_address or "",
|
|
||||||
"store_phone_number": store_info.store_phone_number or "",
|
|
||||||
"description": store_info.store_info or "",
|
|
||||||
"prompt": form_data.prompts,
|
|
||||||
"attr_category": ", ".join(form_data.attributes.keys())
|
|
||||||
if form_data.attributes
|
|
||||||
else "",
|
|
||||||
"attr_value": ", ".join(form_data.attributes.values())
|
|
||||||
if form_data.attributes
|
|
||||||
else "",
|
|
||||||
"ai": "ChatGPT",
|
|
||||||
"ai_model": form_data.llm_model,
|
|
||||||
"genre": "후크송",
|
|
||||||
"sample_song": combined_sample_song or "없음",
|
|
||||||
"result_song": final_lyrics,
|
|
||||||
}
|
|
||||||
|
|
||||||
await conn.execute(text(insert_query), insert_params)
|
|
||||||
await conn.commit()
|
|
||||||
|
|
||||||
print("결과 저장 완료")
|
|
||||||
|
|
||||||
print("\n전체 결과 조회 중...")
|
|
||||||
|
|
||||||
# 9. 생성 결과 가져오기 (created_at 역순)
|
|
||||||
select_query = """
|
|
||||||
SELECT * FROM song_results_all
|
|
||||||
ORDER BY created_at DESC;
|
|
||||||
"""
|
|
||||||
|
|
||||||
all_results = await conn.execute(text(select_query))
|
|
||||||
|
|
||||||
results_list = [
|
|
||||||
{
|
|
||||||
"id": row.id,
|
|
||||||
"store_info": row.store_info,
|
|
||||||
"store_name": row.store_name,
|
|
||||||
"store_category": row.store_category,
|
|
||||||
"store_address": row.store_address,
|
|
||||||
"store_phone_number": row.store_phone_number,
|
|
||||||
"description": row.description,
|
|
||||||
"prompt": row.prompt,
|
|
||||||
"attr_category": row.attr_category,
|
|
||||||
"attr_value": row.attr_value,
|
|
||||||
"ai": row.ai,
|
|
||||||
"ai_model": row.ai_model,
|
|
||||||
"genre": row.genre,
|
|
||||||
"sample_song": row.sample_song,
|
|
||||||
"result_song": row.result_song,
|
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
||||||
}
|
|
||||||
for row in all_results.fetchall()
|
|
||||||
]
|
|
||||||
|
|
||||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
|
||||||
|
|
||||||
return results_list
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
print(f"Database Error: {e}")
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Unexpected Error: {e}")
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_song_result(conn: Connection): # 반환 타입 수정
|
|
||||||
try:
|
|
||||||
select_query = """
|
|
||||||
SELECT * FROM song_results_all
|
|
||||||
ORDER BY created_at DESC;
|
|
||||||
"""
|
|
||||||
|
|
||||||
all_results = await conn.execute(text(select_query))
|
|
||||||
|
|
||||||
results_list = [
|
|
||||||
{
|
|
||||||
"id": row.id,
|
|
||||||
"store_info": row.store_info,
|
|
||||||
"store_name": row.store_name,
|
|
||||||
"store_category": row.store_category,
|
|
||||||
"store_address": row.store_address,
|
|
||||||
"store_phone_number": row.store_phone_number,
|
|
||||||
"description": row.description,
|
|
||||||
"prompt": row.prompt,
|
|
||||||
"attr_category": row.attr_category,
|
|
||||||
"attr_value": row.attr_value,
|
|
||||||
"ai": row.ai,
|
|
||||||
"ai_model": row.ai_model,
|
|
||||||
"season": row.season,
|
|
||||||
"num_of_people": row.num_of_people,
|
|
||||||
"people_category": row.people_category,
|
|
||||||
"genre": row.genre,
|
|
||||||
"sample_song": row.sample_song,
|
|
||||||
"result_song": row.result_song,
|
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
||||||
}
|
|
||||||
for row in all_results.fetchall()
|
|
||||||
]
|
|
||||||
|
|
||||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
|
||||||
|
|
||||||
return results_list
|
|
||||||
except HTTPException: # HTTPException은 그대로 raise
|
|
||||||
raise
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
print(f"Database Error: {e}")
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Unexpected Error: {e}")
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def make_automation(request: Request, conn: Connection):
|
|
||||||
try:
|
|
||||||
# 1. Form 데이터 파싱
|
|
||||||
form_data = await SongFormData.from_form(request)
|
|
||||||
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print(f"Store ID: {form_data.store_id}")
|
|
||||||
print(f"{'=' * 60}\n")
|
|
||||||
|
|
||||||
# 2. Store 정보 조회
|
|
||||||
store_query = """
|
|
||||||
SELECT * FROM store_default_info WHERE id=:id;
|
|
||||||
"""
|
|
||||||
store_result = await conn.execute(text(store_query), {"id": form_data.store_id})
|
|
||||||
|
|
||||||
all_store_info = [
|
|
||||||
StoreData(
|
|
||||||
id=row[0],
|
|
||||||
store_info=row[1],
|
|
||||||
store_name=row[2],
|
|
||||||
store_category=row[3],
|
|
||||||
store_region=row[4],
|
|
||||||
store_address=row[5],
|
|
||||||
store_phone_number=row[6],
|
|
||||||
created_at=row[7],
|
|
||||||
)
|
|
||||||
for row in store_result
|
|
||||||
]
|
|
||||||
|
|
||||||
if not all_store_info:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Store not found: {form_data.store_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
store_info = all_store_info[0]
|
|
||||||
print(f"Store: {store_info.store_name}")
|
|
||||||
|
|
||||||
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
|
||||||
attribute_query = """
|
|
||||||
SELECT * FROM attribute;
|
|
||||||
"""
|
|
||||||
|
|
||||||
attribute_results = await conn.execute(text(attribute_query))
|
|
||||||
|
|
||||||
# 결과 가져오기
|
|
||||||
attribute_rows = attribute_results.fetchall()
|
|
||||||
|
|
||||||
formatted_attributes = ""
|
|
||||||
selected_categories = []
|
|
||||||
selected_values = []
|
|
||||||
|
|
||||||
if attribute_rows:
|
|
||||||
attribute_list = [
|
|
||||||
AttributeData(
|
|
||||||
id=row[0],
|
|
||||||
attr_category=row[1],
|
|
||||||
attr_value=row[2],
|
|
||||||
created_at=row[3],
|
|
||||||
)
|
|
||||||
for row in attribute_rows
|
|
||||||
]
|
|
||||||
|
|
||||||
# ✅ 각 category에서 하나의 value만 랜덤 선택
|
|
||||||
formatted_pairs = []
|
|
||||||
for attr in attribute_list:
|
|
||||||
# 쉼표로 분리 및 공백 제거
|
|
||||||
values = [v.strip() for v in attr.attr_value.split(",") if v.strip()]
|
|
||||||
|
|
||||||
if values:
|
|
||||||
# 랜덤하게 하나만 선택
|
|
||||||
selected_value = random.choice(values)
|
|
||||||
formatted_pairs.append(f"{attr.attr_category} : {selected_value}")
|
|
||||||
|
|
||||||
# ✅ 선택된 category와 value 저장
|
|
||||||
selected_categories.append(attr.attr_category)
|
|
||||||
selected_values.append(selected_value)
|
|
||||||
|
|
||||||
# 최종 문자열 생성
|
|
||||||
formatted_attributes = "\n".join(formatted_pairs)
|
|
||||||
|
|
||||||
print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n")
|
|
||||||
else:
|
|
||||||
print("속성 데이터가 없습니다")
|
|
||||||
formatted_attributes = ""
|
|
||||||
|
|
||||||
# 4. 템플릿 가져오기
|
|
||||||
print("템플릿 가져오기 (ID=1)")
|
|
||||||
|
|
||||||
prompts_query = """
|
|
||||||
SELECT * FROM prompt_template WHERE id=1;
|
|
||||||
"""
|
|
||||||
|
|
||||||
prompts_result = await conn.execute(text(prompts_query))
|
|
||||||
|
|
||||||
row = prompts_result.fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Prompt ID 1 not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = PromptTemplateData(
|
|
||||||
id=row[0],
|
|
||||||
description=row[1],
|
|
||||||
prompt=row[2],
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"Prompt Template: {prompt.prompt}")
|
|
||||||
|
|
||||||
# 5. 템플릿 조합
|
|
||||||
|
|
||||||
updated_prompt = prompt.prompt.replace("###", formatted_attributes).format(
|
|
||||||
name=store_info.store_name or "",
|
|
||||||
address=store_info.store_address or "",
|
|
||||||
category=store_info.store_category or "",
|
|
||||||
description=store_info.store_info or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("업데이트된 프롬프트")
|
|
||||||
print("=" * 80)
|
|
||||||
print(updated_prompt)
|
|
||||||
print("=" * 80 + "\n")
|
|
||||||
|
|
||||||
# 4. Sample Song 조회 및 결합
|
|
||||||
combined_sample_song = None
|
|
||||||
|
|
||||||
if form_data.lyrics_ids:
|
|
||||||
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
|
||||||
|
|
||||||
lyrics_query = """
|
|
||||||
SELECT sample_song FROM song_sample
|
|
||||||
WHERE id IN :ids
|
|
||||||
ORDER BY created_at;
|
|
||||||
"""
|
|
||||||
lyrics_result = await conn.execute(
|
|
||||||
text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)}
|
|
||||||
)
|
|
||||||
|
|
||||||
sample_songs = [
|
|
||||||
row.sample_song for row in lyrics_result.fetchall() if row.sample_song
|
|
||||||
]
|
|
||||||
|
|
||||||
if sample_songs:
|
|
||||||
combined_sample_song = "\n\n".join(
|
|
||||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
|
||||||
)
|
|
||||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
|
||||||
else:
|
|
||||||
print("샘플 가사가 비어있습니다")
|
|
||||||
else:
|
|
||||||
print("선택된 lyrics가 없습니다")
|
|
||||||
|
|
||||||
# 1. song_sample 테이블의 모든 ID 조회
|
|
||||||
print("\n[샘플 가사 랜덤 선택]")
|
|
||||||
|
|
||||||
all_ids_query = """
|
|
||||||
SELECT id FROM song_sample;
|
|
||||||
"""
|
|
||||||
ids_result = await conn.execute(text(all_ids_query))
|
|
||||||
all_ids = [row.id for row in ids_result.fetchall()]
|
|
||||||
|
|
||||||
print(f"전체 샘플 가사 개수: {len(all_ids)}개")
|
|
||||||
|
|
||||||
# 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체)
|
|
||||||
combined_sample_song = None
|
|
||||||
|
|
||||||
if all_ids:
|
|
||||||
# 3개 또는 전체 개수 중 작은 값 선택
|
|
||||||
sample_count = min(3, len(all_ids))
|
|
||||||
selected_ids = random.sample(all_ids, sample_count)
|
|
||||||
|
|
||||||
print(f"랜덤 선택된 ID: {selected_ids}")
|
|
||||||
|
|
||||||
# 3. 선택된 ID로 샘플 가사 조회
|
|
||||||
lyrics_query = """
|
|
||||||
SELECT sample_song FROM song_sample
|
|
||||||
WHERE id IN :ids
|
|
||||||
ORDER BY created_at;
|
|
||||||
"""
|
|
||||||
lyrics_result = await conn.execute(
|
|
||||||
text(lyrics_query), {"ids": tuple(selected_ids)}
|
|
||||||
)
|
|
||||||
|
|
||||||
sample_songs = [
|
|
||||||
row.sample_song for row in lyrics_result.fetchall() if row.sample_song
|
|
||||||
]
|
|
||||||
|
|
||||||
# 4. combined_sample_song 생성
|
|
||||||
if sample_songs:
|
|
||||||
combined_sample_song = "\n\n".join(
|
|
||||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
|
||||||
)
|
|
||||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
|
||||||
else:
|
|
||||||
print("샘플 가사가 비어있습니다")
|
|
||||||
else:
|
|
||||||
print("song_sample 테이블에 데이터가 없습니다")
|
|
||||||
|
|
||||||
# 5. 프롬프트에 샘플 가사 추가
|
|
||||||
if combined_sample_song:
|
|
||||||
updated_prompt += f"""
|
|
||||||
|
|
||||||
다음은 참고해야 하는 샘플 가사 정보입니다.
|
|
||||||
|
|
||||||
샘플 가사를 참고하여 작곡을 해주세요.
|
|
||||||
|
|
||||||
{combined_sample_song}
|
|
||||||
"""
|
|
||||||
print("샘플 가사 정보가 프롬프트에 추가되었습니다")
|
|
||||||
else:
|
|
||||||
print("샘플 가사가 없어 기본 프롬프트만 사용합니다")
|
|
||||||
|
|
||||||
print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n")
|
|
||||||
|
|
||||||
# 7. 모델에게 요청
|
|
||||||
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
|
|
||||||
|
|
||||||
# 글자 수 계산
|
|
||||||
total_chars_with_space = len(generated_lyrics)
|
|
||||||
total_chars_without_space = len(
|
|
||||||
generated_lyrics.replace(" ", "")
|
|
||||||
.replace("\n", "")
|
|
||||||
.replace("\r", "")
|
|
||||||
.replace("\t", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
# final_lyrics 생성
|
|
||||||
final_lyrics = f"""속성 {formatted_attributes}
|
|
||||||
전체 글자 수 (공백 포함): {total_chars_with_space}자
|
|
||||||
전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}"""
|
|
||||||
|
|
||||||
# 8. DB 저장
|
|
||||||
insert_query = """
|
|
||||||
INSERT INTO song_results_all (
|
|
||||||
store_info, store_name, store_category, store_address, store_phone_number,
|
|
||||||
description, prompt, attr_category, attr_value,
|
|
||||||
ai, ai_model, genre,
|
|
||||||
sample_song, result_song, created_at
|
|
||||||
) VALUES (
|
|
||||||
:store_info, :store_name, :store_category, :store_address, :store_phone_number,
|
|
||||||
:description, :prompt, :attr_category, :attr_value,
|
|
||||||
:ai, :ai_model, :genre,
|
|
||||||
:sample_song, :result_song, NOW()
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
print("\n[insert_params 선택된 속성 확인]")
|
|
||||||
print(f"Categories: {selected_categories}")
|
|
||||||
print(f"Values: {selected_values}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# attr_category, attr_value
|
|
||||||
insert_params = {
|
|
||||||
"store_info": store_info.store_info or "",
|
|
||||||
"store_name": store_info.store_name,
|
|
||||||
"store_category": store_info.store_category or "",
|
|
||||||
"store_address": store_info.store_address or "",
|
|
||||||
"store_phone_number": store_info.store_phone_number or "",
|
|
||||||
"description": store_info.store_info or "",
|
|
||||||
"prompt": prompt.id,
|
|
||||||
# 랜덤 선택된 category와 value 사용
|
|
||||||
"attr_category": ", ".join(selected_categories)
|
|
||||||
if selected_categories
|
|
||||||
else "",
|
|
||||||
"attr_value": ", ".join(selected_values) if selected_values else "",
|
|
||||||
"ai": "ChatGPT",
|
|
||||||
"ai_model": "gpt-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="서비스 처리 중 오류가 발생했습니다.",
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
"""
|
||||||
|
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}")
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
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": "생성일시",
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
"""
|
||||||
|
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")>"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
"""
|
||||||
|
Social Media Integration Module
|
||||||
|
|
||||||
|
소셜 미디어 플랫폼 연동 및 영상 업로드 기능을 제공합니다.
|
||||||
|
|
||||||
|
지원 플랫폼:
|
||||||
|
- YouTube (구현됨)
|
||||||
|
- Instagram (추후 구현)
|
||||||
|
- Facebook (추후 구현)
|
||||||
|
- TikTok (추후 구현)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.social.constants import SocialPlatform, UploadStatus
|
||||||
|
|
||||||
|
__all__ = ["SocialPlatform", "UploadStatus"]
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Social API Module
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Social API Routers
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""
|
||||||
|
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"]
|
||||||
|
|
@ -0,0 +1,327 @@
|
||||||
|
"""
|
||||||
|
소셜 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} 계정 연동이 해제되었습니다.",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
@ -0,0 +1,424 @@
|
||||||
|
"""
|
||||||
|
소셜 업로드 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="업로드가 취소되었습니다.",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""
|
||||||
|
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",
|
||||||
|
# ]
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
"""
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
"""
|
||||||
|
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")>"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,326 @@
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
|
@ -0,0 +1,324 @@
|
||||||
|
"""
|
||||||
|
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": "작업이 완료되었습니다.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,742 @@
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""
|
||||||
|
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 ""
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""
|
||||||
|
Social Worker Module
|
||||||
|
|
||||||
|
소셜 미디어 백그라운드 태스크 모듈입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.social.worker.upload_task import process_social_upload
|
||||||
|
|
||||||
|
__all__ = ["process_social_upload"]
|
||||||
|
|
@ -0,0 +1,386 @@
|
||||||
|
"""
|
||||||
|
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}")
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Song API v1 라우터 모듈
|
||||||
|
"""
|
||||||
|
|
@ -5,39 +5,35 @@ Song API Router
|
||||||
|
|
||||||
엔드포인트 목록:
|
엔드포인트 목록:
|
||||||
- POST /song/generate/{task_id}: 노래 생성 요청 (task_id로 Project/Lyric 연결)
|
- POST /song/generate/{task_id}: 노래 생성 요청 (task_id로 Project/Lyric 연결)
|
||||||
- GET /song/status/{suno_task_id}: Suno API 노래 생성 상태 조회
|
- GET /song/status/{song_id}: Suno API 노래 생성 상태 조회
|
||||||
- GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling)
|
|
||||||
|
|
||||||
사용 예시:
|
사용 예시:
|
||||||
from app.song.api.routers.v1.song import router
|
from app.song.api.routers.v1.song import router
|
||||||
app.include_router(router, prefix="/api/v1")
|
app.include_router(router)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
from app.dependencies.pagination import (
|
|
||||||
PaginationParams,
|
|
||||||
get_pagination_params,
|
|
||||||
)
|
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
|
from app.user.dependencies.auth import get_current_user
|
||||||
|
from app.user.models import User
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.song.models import Song
|
from app.song.models import Song, SongTimestamp
|
||||||
from app.song.schemas.song_schema import (
|
from app.song.schemas.song_schema import (
|
||||||
DownloadSongResponse,
|
|
||||||
GenerateSongRequest,
|
GenerateSongRequest,
|
||||||
GenerateSongResponse,
|
GenerateSongResponse,
|
||||||
PollingSongResponse,
|
PollingSongResponse,
|
||||||
SongListItem,
|
|
||||||
)
|
)
|
||||||
from app.song.worker.song_task import download_and_upload_song_by_suno_task_id
|
from app.song.worker.song_task import download_and_upload_song_by_suno_task_id
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.logger import get_logger
|
||||||
from app.utils.suno import SunoService
|
from app.utils.suno import SunoService
|
||||||
|
|
||||||
|
logger = get_logger("song")
|
||||||
|
|
||||||
router = APIRouter(prefix="/song", tags=["song"])
|
router = APIRouter(prefix="/song", tags=["Song"])
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|
@ -46,6 +42,9 @@ router = APIRouter(prefix="/song", tags=["song"])
|
||||||
description="""
|
description="""
|
||||||
Suno API를 통해 노래 생성을 요청합니다.
|
Suno API를 통해 노래 생성을 요청합니다.
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||||
|
|
||||||
## 경로 파라미터
|
## 경로 파라미터
|
||||||
- **task_id**: Project/Lyric의 task_id (필수) - 연관된 프로젝트와 가사를 조회하는 데 사용
|
- **task_id**: Project/Lyric의 task_id (필수) - 연관된 프로젝트와 가사를 조회하는 데 사용
|
||||||
|
|
||||||
|
|
@ -57,27 +56,30 @@ Suno API를 통해 노래 생성을 요청합니다.
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **success**: 요청 성공 여부
|
- **success**: 요청 성공 여부
|
||||||
- **task_id**: 내부 작업 ID (Project/Lyric task_id)
|
- **task_id**: 내부 작업 ID (Project/Lyric task_id)
|
||||||
- **suno_task_id**: Suno API 작업 ID (상태 조회에 사용)
|
- **song_id**: Suno API 작업 ID (상태 조회에 사용)
|
||||||
- **message**: 응답 메시지
|
- **message**: 응답 메시지
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시 (cURL)
|
||||||
```
|
```bash
|
||||||
POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
|
curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234567890" \\
|
||||||
{
|
-H "Authorization: Bearer {access_token}" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
"lyrics": "여기 군산에서 만나요\\n아름다운 하루를 함께",
|
"lyrics": "여기 군산에서 만나요\\n아름다운 하루를 함께",
|
||||||
"genre": "K-Pop",
|
"genre": "K-Pop",
|
||||||
"language": "Korean"
|
"language": "Korean"
|
||||||
}
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 생성되는 노래는 약 1분 이내 길이입니다.
|
- 생성되는 노래는 약 1분 이내 길이입니다.
|
||||||
- suno_task_id를 사용하여 /status/{suno_task_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
||||||
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
||||||
""",
|
""",
|
||||||
response_model=GenerateSongResponse,
|
response_model=GenerateSongResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "노래 생성 요청 성공"},
|
200: {"description": "노래 생성 요청 성공"},
|
||||||
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||||
404: {"description": "Project 또는 Lyric을 찾을 수 없음"},
|
404: {"description": "Project 또는 Lyric을 찾을 수 없음"},
|
||||||
500: {"description": "노래 생성 요청 실패"},
|
500: {"description": "노래 생성 요청 실패"},
|
||||||
},
|
},
|
||||||
|
|
@ -85,53 +87,107 @@ POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
|
||||||
async def generate_song(
|
async def generate_song(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
request_body: GenerateSongRequest,
|
request_body: GenerateSongRequest,
|
||||||
session: AsyncSession = Depends(get_session),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> GenerateSongResponse:
|
) -> GenerateSongResponse:
|
||||||
"""가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다.
|
"""가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다.
|
||||||
|
|
||||||
1. task_id로 Project와 Lyric 조회
|
1. task_id로 Project와 Lyric 조회
|
||||||
2. Song 테이블에 초기 데이터 저장 (status: processing)
|
2. Song 테이블에 초기 데이터 저장 (status: processing)
|
||||||
3. Suno API 호출
|
3. Suno API 호출 (세션 닫힌 상태)
|
||||||
4. suno_task_id 업데이트 후 응답 반환
|
4. suno_task_id 업데이트 후 응답 반환
|
||||||
|
|
||||||
|
Note: 이 함수는 Depends(get_session)을 사용하지 않고 명시적으로 세션을 관리합니다.
|
||||||
|
외부 API 호출 중 DB 커넥션이 유지되지 않도록 하여 커넥션 타임아웃 문제를 방지합니다.
|
||||||
"""
|
"""
|
||||||
print(f"[generate_song] START - task_id: {task_id}, genre: {request_body.genre}, language: {request_body.language}")
|
import time
|
||||||
|
from app.database.session import AsyncSessionLocal
|
||||||
|
|
||||||
|
request_start = time.perf_counter()
|
||||||
|
logger.info(
|
||||||
|
f"[generate_song] START - task_id: {task_id}, "
|
||||||
|
f"genre: {request_body.genre}, language: {request_body.language}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 외부 API 호출 전에 필요한 데이터를 저장할 변수들
|
||||||
|
project_id: int | None = None
|
||||||
|
lyric_id: int | None = None
|
||||||
|
song_id: int | None = None
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음)
|
||||||
|
# ==========================================================================
|
||||||
try:
|
try:
|
||||||
# 1. task_id로 Project 조회
|
async with AsyncSessionLocal() as session:
|
||||||
|
# Project 조회 (중복 시 최신 것 선택)
|
||||||
project_result = await session.execute(
|
project_result = await session.execute(
|
||||||
select(Project).where(Project.task_id == task_id)
|
select(Project)
|
||||||
|
.where(Project.task_id == task_id)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
)
|
)
|
||||||
project = project_result.scalar_one_or_none()
|
project = project_result.scalar_one_or_none()
|
||||||
|
|
||||||
if not project:
|
if not project:
|
||||||
print(f"[generate_song] Project NOT FOUND - task_id: {task_id}")
|
logger.warning(
|
||||||
|
f"[generate_song] Project NOT FOUND - task_id: {task_id}"
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||||
)
|
)
|
||||||
print(f"[generate_song] Project found - project_id: {project.id}, task_id: {task_id}")
|
project_id = project.id
|
||||||
|
|
||||||
# 2. task_id로 Lyric 조회
|
# Lyric 조회 (중복 시 최신 것 선택)
|
||||||
lyric_result = await session.execute(
|
lyric_result = await session.execute(
|
||||||
select(Lyric).where(Lyric.task_id == task_id)
|
select(Lyric)
|
||||||
|
.where(Lyric.task_id == task_id)
|
||||||
|
.order_by(Lyric.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
)
|
)
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
|
logger.debug(
|
||||||
|
f"[generate_song] Lyric query result - "
|
||||||
|
f"id: {lyric.id if lyric else None}, "
|
||||||
|
f"project_id: {lyric.project_id if lyric else None}, "
|
||||||
|
f"task_id: {lyric.task_id if lyric else None}, "
|
||||||
|
f"lyric_result: {lyric.lyric_result if lyric else None}"
|
||||||
|
)
|
||||||
|
|
||||||
if not lyric:
|
if not lyric:
|
||||||
print(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}")
|
logger.warning(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
|
||||||
)
|
)
|
||||||
print(f"[generate_song] Lyric found - lyric_id: {lyric.id}, task_id: {task_id}")
|
lyric_id = lyric.id
|
||||||
|
|
||||||
# 3. Song 테이블에 초기 데이터 저장
|
query_time = time.perf_counter()
|
||||||
|
logger.info(
|
||||||
|
f"[generate_song] Queries completed - task_id: {task_id}, "
|
||||||
|
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
||||||
|
f"elapsed: {(query_time - request_start) * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Song 테이블에 초기 데이터 저장
|
||||||
song_prompt = (
|
song_prompt = (
|
||||||
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
|
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
|
||||||
)
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[generate_song] Lyrics comparison - task_id: {task_id}\n"
|
||||||
|
f"{'=' * 60}\n"
|
||||||
|
f"[lyric.lyric_result]\n"
|
||||||
|
f"{'-' * 60}\n"
|
||||||
|
f"{lyric.lyric_result}\n"
|
||||||
|
f"{'=' * 60}\n"
|
||||||
|
f"[song_prompt]\n"
|
||||||
|
f"{'-' * 60}\n"
|
||||||
|
f"{song_prompt}\n"
|
||||||
|
f"{'=' * 60}"
|
||||||
|
)
|
||||||
|
|
||||||
song = Song(
|
song = Song(
|
||||||
project_id=project.id,
|
project_id=project_id,
|
||||||
lyric_id=lyric.id,
|
lyric_id=lyric_id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=None,
|
suno_task_id=None,
|
||||||
status="processing",
|
status="processing",
|
||||||
|
|
@ -139,385 +195,354 @@ async def generate_song(
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
)
|
)
|
||||||
session.add(song)
|
session.add(song)
|
||||||
await session.flush() # ID 생성을 위해 flush
|
await session.commit()
|
||||||
print(f"[generate_song] Song saved (processing) - task_id: {task_id}")
|
song_id = song.id
|
||||||
|
|
||||||
# 4. Suno API 호출
|
stage1_time = time.perf_counter()
|
||||||
print(f"[generate_song] Suno API generation started - task_id: {task_id}")
|
logger.info(
|
||||||
|
f"[generate_song] Stage 1 DONE - Song saved - "
|
||||||
|
f"task_id: {task_id}, song_id: {song_id}, "
|
||||||
|
f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
# 세션이 여기서 자동으로 닫힘
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[generate_song] Stage 1 EXCEPTION - "
|
||||||
|
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
return GenerateSongResponse(
|
||||||
|
success=False,
|
||||||
|
task_id=task_id,
|
||||||
|
song_id=None,
|
||||||
|
message="노래 생성 요청에 실패했습니다.",
|
||||||
|
error_message=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음)
|
||||||
|
# ==========================================================================
|
||||||
|
stage2_start = time.perf_counter()
|
||||||
|
suno_task_id: str | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"[generate_song] Stage 2 START - Suno API - task_id: {task_id}")
|
||||||
suno_service = SunoService()
|
suno_service = SunoService()
|
||||||
suno_task_id = await suno_service.generate(
|
suno_task_id = await suno_service.generate(
|
||||||
prompt=request_body.lyrics,
|
prompt=request_body.lyrics,
|
||||||
genre=request_body.genre,
|
genre=request_body.genre,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. suno_task_id 업데이트
|
stage2_time = time.perf_counter()
|
||||||
song.suno_task_id = suno_task_id
|
logger.info(
|
||||||
await session.commit()
|
f"[generate_song] Stage 2 DONE - task_id: {task_id}, "
|
||||||
print(f"[generate_song] SUCCESS - task_id: {task_id}, suno_task_id: {suno_task_id}")
|
f"suno_task_id: {suno_task_id}, "
|
||||||
|
f"elapsed: {(stage2_time - stage2_start) * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[generate_song] Stage 2 EXCEPTION - Suno API failed - "
|
||||||
|
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
# 외부 API 실패 시 Song 상태를 failed로 업데이트
|
||||||
|
async with AsyncSessionLocal() as update_session:
|
||||||
|
song_result = await update_session.execute(
|
||||||
|
select(Song).where(Song.id == song_id)
|
||||||
|
)
|
||||||
|
song_to_update = song_result.scalar_one_or_none()
|
||||||
|
if song_to_update:
|
||||||
|
song_to_update.status = "failed"
|
||||||
|
await update_session.commit()
|
||||||
|
|
||||||
|
return GenerateSongResponse(
|
||||||
|
success=False,
|
||||||
|
task_id=task_id,
|
||||||
|
song_id=None,
|
||||||
|
message="노래 생성 요청에 실패했습니다.",
|
||||||
|
error_message=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 3단계: suno_task_id 업데이트 (새 세션으로 빠르게 처리)
|
||||||
|
# ==========================================================================
|
||||||
|
stage3_start = time.perf_counter()
|
||||||
|
logger.info(f"[generate_song] Stage 3 START - DB update - task_id: {task_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as update_session:
|
||||||
|
song_result = await update_session.execute(
|
||||||
|
select(Song).where(Song.id == song_id)
|
||||||
|
)
|
||||||
|
song_to_update = song_result.scalar_one_or_none()
|
||||||
|
if song_to_update:
|
||||||
|
song_to_update.suno_task_id = suno_task_id
|
||||||
|
await update_session.commit()
|
||||||
|
|
||||||
|
stage3_time = time.perf_counter()
|
||||||
|
total_time = stage3_time - request_start
|
||||||
|
logger.info(
|
||||||
|
f"[generate_song] Stage 3 DONE - task_id: {task_id}, "
|
||||||
|
f"elapsed: {(stage3_time - stage3_start) * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"[generate_song] SUCCESS - task_id: {task_id}, "
|
||||||
|
f"suno_task_id: {suno_task_id}, "
|
||||||
|
f"total_time: {total_time * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=True,
|
success=True,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=suno_task_id,
|
song_id=suno_task_id,
|
||||||
message="노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
|
message="노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.",
|
||||||
error_message=None,
|
error_message=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[generate_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
logger.error(
|
||||||
await session.rollback()
|
f"[generate_song] Stage 3 EXCEPTION - "
|
||||||
|
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=False,
|
success=False,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=None,
|
song_id=suno_task_id,
|
||||||
message="노래 생성 요청에 실패했습니다.",
|
message="노래 생성은 요청되었으나 DB 업데이트에 실패했습니다.",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/status/{suno_task_id}",
|
"/status/{song_id}",
|
||||||
summary="노래 생성 상태 조회",
|
summary="노래 생성 상태 조회 (Suno API)",
|
||||||
description="""
|
description="""
|
||||||
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
|
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
|
||||||
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드를 시작합니다.
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||||
|
|
||||||
## 경로 파라미터
|
## 경로 파라미터
|
||||||
- **suno_task_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수)
|
- **song_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수)
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **success**: 조회 성공 여부
|
- **success**: 조회 성공 여부
|
||||||
- **status**: 작업 상태 (PENDING, processing, SUCCESS, failed)
|
- **status**: Suno API 작업 상태
|
||||||
- **message**: 상태 메시지
|
- **message**: 상태 메시지
|
||||||
- **clips**: 생성된 노래 클립 목록 (완료 시)
|
|
||||||
- **raw_response**: Suno API 원본 응답
|
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시 (cURL)
|
||||||
```
|
```bash
|
||||||
GET /song/status/abc123...
|
curl -X GET "http://localhost:8000/song/status/{song_id}" \\
|
||||||
|
-H "Authorization: Bearer {access_token}"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 상태 값
|
## 상태 값 (Suno API 응답)
|
||||||
- **PENDING**: 대기 중
|
- **PENDING**: Suno API 대기 중
|
||||||
- **processing**: 생성 중
|
- **processing**: Suno API에서 노래 생성 중
|
||||||
- **SUCCESS**: 생성 완료
|
- **SUCCESS**: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작)
|
||||||
- **failed**: 생성 실패
|
- **TEXT_SUCCESS**: Suno API 노래 생성 완료
|
||||||
|
- **failed**: Suno API 노래 생성 실패
|
||||||
|
- **error**: API 조회 오류
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 스트림 URL: 30-40초 내 생성
|
- 이 엔드포인트는 Suno API의 상태를 반환합니다
|
||||||
- 다운로드 URL: 2-3분 내 생성
|
- SUCCESS 응답 시 백그라운드에서 MP3 다운로드 → Azure Blob Storage 업로드가 시작됩니다
|
||||||
- SUCCESS 시 백그라운드에서 MP3 다운로드 → Azure Blob Storage 업로드 → Song 테이블 업데이트 진행
|
- Song 테이블 상태: processing → uploading → completed
|
||||||
- 저장 경로: Azure Blob Storage ({BASE_URL}/{task_id}/song/{store_name}.mp3)
|
|
||||||
- Song 테이블의 song_result_url에 Blob URL이 저장됩니다
|
|
||||||
""",
|
""",
|
||||||
response_model=PollingSongResponse,
|
response_model=PollingSongResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "상태 조회 성공"},
|
200: {"description": "상태 조회 성공"},
|
||||||
500: {"description": "상태 조회 실패"},
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_song_status(
|
async def get_song_status(
|
||||||
suno_task_id: str,
|
song_id: str,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> PollingSongResponse:
|
) -> PollingSongResponse:
|
||||||
"""suno_task_id로 노래 생성 작업의 상태를 조회합니다.
|
"""song_id로 노래 생성 작업의 상태를 조회합니다.
|
||||||
|
|
||||||
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
|
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
|
||||||
Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로,
|
Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로,
|
||||||
song_result_url을 Blob URL로 업데이트합니다.
|
song_result_url을 Blob URL로 업데이트합니다.
|
||||||
"""
|
"""
|
||||||
print(f"[get_song_status] START - suno_task_id: {suno_task_id}")
|
suno_task_id = song_id # 임시방편 / 외부 suno 노출 방지
|
||||||
|
logger.info(f"[get_song_status] START - song_id: {suno_task_id}")
|
||||||
try:
|
try:
|
||||||
suno_service = SunoService()
|
suno_service = SunoService()
|
||||||
result = await suno_service.get_task_status(suno_task_id)
|
result = await suno_service.get_task_status(suno_task_id)
|
||||||
|
logger.debug(
|
||||||
|
f"[get_song_status] Suno API raw response - song_id: {suno_task_id}, result: {result}"
|
||||||
|
)
|
||||||
parsed_response = suno_service.parse_status_response(result)
|
parsed_response = suno_service.parse_status_response(result)
|
||||||
print(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}")
|
logger.info(
|
||||||
|
f"[get_song_status] Suno API response - song_id: {suno_task_id}, status: {parsed_response.status}"
|
||||||
|
)
|
||||||
|
|
||||||
# SUCCESS 상태인 경우 백그라운드 태스크 실행
|
if parsed_response.status == "TEXT_SUCCESS" and result:
|
||||||
if parsed_response.status == "SUCCESS" and parsed_response.clips:
|
parsed_response.status = "processing"
|
||||||
# 첫 번째 클립의 audioUrl과 duration 가져오기
|
return parsed_response
|
||||||
first_clip = parsed_response.clips[0]
|
|
||||||
audio_url = first_clip.audio_url
|
# SUCCESS 상태인 경우 백그라운드에서 MP3 다운로드 및 Blob 업로드 진행
|
||||||
clip_duration = first_clip.duration
|
if parsed_response.status == "SUCCESS" and result:
|
||||||
|
# result에서 직접 clips 데이터 추출
|
||||||
|
data = result.get("data", {})
|
||||||
|
response_data = data.get("response") or {}
|
||||||
|
clips_data = response_data.get("sunoData") or []
|
||||||
|
|
||||||
|
if clips_data:
|
||||||
|
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
|
||||||
|
first_clip = clips_data[0]
|
||||||
|
audio_url = first_clip.get("audioUrl")
|
||||||
|
clip_duration = first_clip.get("duration")
|
||||||
|
logger.debug(
|
||||||
|
f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}"
|
||||||
|
)
|
||||||
|
|
||||||
if audio_url:
|
if audio_url:
|
||||||
# suno_task_id로 Song 조회하여 store_name 가져오기
|
# song_id로 Song 조회
|
||||||
song_result = await session.execute(
|
song_result = await session.execute(
|
||||||
select(Song)
|
select(Song)
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
.where(Song.suno_task_id == song_id)
|
||||||
.order_by(Song.created_at.desc())
|
.order_by(Song.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
song = song_result.scalar_one_or_none()
|
song = song_result.scalar_one_or_none()
|
||||||
|
|
||||||
if song and song.status != "completed":
|
# processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
|
||||||
# 이미 완료된 경우 백그라운드 작업 중복 실행 방지
|
if song and song.status == "processing":
|
||||||
# project_id로 Project 조회하여 store_name 가져오기
|
# 상태를 uploading으로 변경 (중복 호출 방지)
|
||||||
project_result = await session.execute(
|
song.status = "uploading"
|
||||||
select(Project).where(Project.id == song.project_id)
|
song.suno_audio_id = first_clip.get("id")
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
f"[get_song_status] Song status changed to uploading - song_id: {suno_task_id}"
|
||||||
)
|
)
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
store_name = project.store_name if project else "song"
|
# 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행
|
||||||
|
|
||||||
# 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드, DB 업데이트 (suno_task_id 사용)
|
|
||||||
print(f"[get_song_status] Background task args - suno_task_id: {suno_task_id}, audio_url: {audio_url}, store_name: {store_name}, duration: {clip_duration}")
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
download_and_upload_song_by_suno_task_id,
|
download_and_upload_song_by_suno_task_id,
|
||||||
suno_task_id=suno_task_id,
|
suno_task_id=song_id,
|
||||||
audio_url=audio_url,
|
audio_url=audio_url,
|
||||||
store_name=store_name,
|
user_uuid=current_user.user_uuid,
|
||||||
duration=clip_duration,
|
duration=clip_duration,
|
||||||
)
|
)
|
||||||
elif song and song.status == "completed":
|
logger.info(
|
||||||
print(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}")
|
f"[get_song_status] Background task scheduled - song_id: {suno_task_id}"
|
||||||
|
)
|
||||||
|
|
||||||
print(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}")
|
suno_audio_id = first_clip.get("id")
|
||||||
|
word_data = await suno_service.get_lyric_timestamp(
|
||||||
|
suno_task_id, suno_audio_id
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[get_song_status] word_data from get_lyric_timestamp - "
|
||||||
|
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
||||||
|
f"word_data: {word_data}"
|
||||||
|
)
|
||||||
|
lyric_result = await session.execute(
|
||||||
|
select(Lyric)
|
||||||
|
.where(Lyric.task_id == song.task_id)
|
||||||
|
.order_by(Lyric.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
|
gt_lyric = lyric.lyric_result
|
||||||
|
lyric_line_list = gt_lyric.split("\n")
|
||||||
|
sentences = [
|
||||||
|
lyric_line.strip(",. ")
|
||||||
|
for lyric_line in lyric_line_list
|
||||||
|
if lyric_line and lyric_line != "---"
|
||||||
|
]
|
||||||
|
logger.debug(
|
||||||
|
f"[get_song_status] sentences from lyric - "
|
||||||
|
f"sentences: {sentences}"
|
||||||
|
)
|
||||||
|
|
||||||
|
timestamped_lyrics = suno_service.align_lyrics(
|
||||||
|
word_data, sentences
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[get_song_status] sentences from lyric - "
|
||||||
|
f"sentences: {sentences}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO : DB upload timestamped_lyrics
|
||||||
|
for order_idx, timestamped_lyric in enumerate(
|
||||||
|
timestamped_lyrics
|
||||||
|
):
|
||||||
|
# start_sec 또는 end_sec가 None인 경우 건너뛰기
|
||||||
|
if (
|
||||||
|
timestamped_lyric["start_sec"] is None
|
||||||
|
or timestamped_lyric["end_sec"] is None
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
f"[get_song_status] Skipping timestamp - "
|
||||||
|
f"lyric_line: {timestamped_lyric['text']}, "
|
||||||
|
f"start_sec: {timestamped_lyric['start_sec']}, "
|
||||||
|
f"end_sec: {timestamped_lyric['end_sec']}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
song_timestamp = SongTimestamp(
|
||||||
|
suno_audio_id=suno_audio_id,
|
||||||
|
order_idx=order_idx,
|
||||||
|
lyric_line=timestamped_lyric["text"],
|
||||||
|
start_time=timestamped_lyric["start_sec"],
|
||||||
|
end_time=timestamped_lyric["end_sec"],
|
||||||
|
)
|
||||||
|
session.add(song_timestamp)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
parsed_response.status = "processing"
|
||||||
|
|
||||||
|
elif song and song.status == "uploading":
|
||||||
|
logger.info(
|
||||||
|
f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}"
|
||||||
|
)
|
||||||
|
parsed_response.status = "uploading"
|
||||||
|
elif song and song.status == "completed":
|
||||||
|
logger.info(
|
||||||
|
f"[get_song_status] SKIPPED - Song already completed, song_id: {suno_task_id}"
|
||||||
|
)
|
||||||
|
parsed_response.song_result_url = song.song_result_url
|
||||||
|
else:
|
||||||
|
# audio_url이 없는 경우 에러 반환
|
||||||
|
logger.error(
|
||||||
|
f"[get_song_status] ERROR - audio_url not found in clips_data, song_id: {suno_task_id}"
|
||||||
|
)
|
||||||
|
return PollingSongResponse(
|
||||||
|
success=False,
|
||||||
|
status="error",
|
||||||
|
message="Suno API 응답에서 audio_url을 찾을 수 없습니다.",
|
||||||
|
error_message="audio_url not found in Suno API response",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# clips_data가 없는 경우 에러 반환
|
||||||
|
logger.error(
|
||||||
|
f"[get_song_status] ERROR - clips_data not found, song_id: {suno_task_id}"
|
||||||
|
)
|
||||||
|
return PollingSongResponse(
|
||||||
|
success=False,
|
||||||
|
status="error",
|
||||||
|
message="Suno API 응답에서 클립 데이터를 찾을 수 없습니다.",
|
||||||
|
error_message="clips_data not found in Suno API response",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[get_song_status] END - song_id: {suno_task_id}")
|
||||||
return parsed_response
|
return parsed_response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
print(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
logger.error(f"[get_song_status] EXCEPTION - song_id: {song_id}, error: {e}")
|
||||||
return PollingSongResponse(
|
return PollingSongResponse(
|
||||||
success=False,
|
success=False,
|
||||||
status="error",
|
status="error",
|
||||||
message="상태 조회에 실패했습니다.",
|
message="상태 조회에 실패했습니다.",
|
||||||
clips=None,
|
|
||||||
raw_response=None,
|
|
||||||
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/download/{task_id}",
|
|
||||||
summary="노래 생성 URL 조회",
|
|
||||||
description="""
|
|
||||||
task_id를 기반으로 Song 테이블의 상태를 조회하고,
|
|
||||||
completed인 경우 Project 정보와 노래 URL을 반환합니다.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **task_id**: 프로젝트 task_id (필수)
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **success**: 조회 성공 여부
|
|
||||||
- **status**: 처리 상태 (processing, completed, failed, not_found)
|
|
||||||
- **message**: 응답 메시지
|
|
||||||
- **store_name**: 업체명
|
|
||||||
- **region**: 지역명
|
|
||||||
- **detail_region_info**: 상세 지역 정보
|
|
||||||
- **task_id**: 작업 고유 식별자
|
|
||||||
- **language**: 언어
|
|
||||||
- **song_result_url**: 노래 결과 URL (completed 시, Azure Blob Storage URL)
|
|
||||||
- **created_at**: 생성 일시
|
|
||||||
|
|
||||||
## 사용 예시
|
|
||||||
```
|
|
||||||
GET /song/download/019123ab-cdef-7890-abcd-ef1234567890
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- processing 상태인 경우 song_result_url은 null입니다.
|
|
||||||
- completed 상태인 경우 Project 정보와 함께 song_result_url (Azure Blob URL)을 반환합니다.
|
|
||||||
- song_result_url 형식: {AZURE_BLOB_BASE_URL}/{task_id}/song/{store_name}.mp3
|
|
||||||
""",
|
|
||||||
response_model=DownloadSongResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "조회 성공"},
|
|
||||||
404: {"description": "Song을 찾을 수 없음"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def download_song(
|
|
||||||
task_id: str,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> DownloadSongResponse:
|
|
||||||
"""task_id로 Song 상태를 polling하고 completed 시 Project 정보와 노래 URL을 반환합니다."""
|
|
||||||
print(f"[download_song] START - task_id: {task_id}")
|
|
||||||
try:
|
|
||||||
# task_id로 Song 조회 (여러 개 있을 경우 가장 최근 것 선택)
|
|
||||||
song_result = await session.execute(
|
|
||||||
select(Song)
|
|
||||||
.where(Song.task_id == task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
song = song_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not song:
|
|
||||||
print(f"[download_song] Song NOT FOUND - task_id: {task_id}")
|
|
||||||
return DownloadSongResponse(
|
|
||||||
success=False,
|
|
||||||
status="not_found",
|
|
||||||
message=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.",
|
|
||||||
error_message="Song not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"[download_song] Song found - task_id: {task_id}, status: {song.status}")
|
|
||||||
|
|
||||||
# processing 상태인 경우
|
|
||||||
if song.status == "processing":
|
|
||||||
print(f"[download_song] PROCESSING - task_id: {task_id}")
|
|
||||||
return DownloadSongResponse(
|
|
||||||
success=True,
|
|
||||||
status="processing",
|
|
||||||
message="노래 생성이 진행 중입니다.",
|
|
||||||
task_id=task_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# failed 상태인 경우
|
|
||||||
if song.status == "failed":
|
|
||||||
print(f"[download_song] FAILED - task_id: {task_id}")
|
|
||||||
return DownloadSongResponse(
|
|
||||||
success=False,
|
|
||||||
status="failed",
|
|
||||||
message="노래 생성에 실패했습니다.",
|
|
||||||
task_id=task_id,
|
|
||||||
error_message="Song generation failed",
|
|
||||||
)
|
|
||||||
|
|
||||||
# completed 상태인 경우 - Project 정보 조회
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project).where(Project.id == song.project_id)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
print(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}")
|
|
||||||
return DownloadSongResponse(
|
|
||||||
success=True,
|
|
||||||
status="completed",
|
|
||||||
message="노래 다운로드가 완료되었습니다.",
|
|
||||||
store_name=project.store_name if project else None,
|
|
||||||
region=project.region if project else None,
|
|
||||||
detail_region_info=project.detail_region_info if project else None,
|
|
||||||
task_id=task_id,
|
|
||||||
language=project.language if project else None,
|
|
||||||
song_result_url=song.song_result_url,
|
|
||||||
created_at=song.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[download_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
return DownloadSongResponse(
|
|
||||||
success=False,
|
|
||||||
status="error",
|
|
||||||
message="노래 다운로드 조회에 실패했습니다.",
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"s/",
|
|
||||||
summary="생성된 노래 목록 조회",
|
|
||||||
description="""
|
|
||||||
완료된 노래 목록을 페이지네이션하여 조회합니다.
|
|
||||||
|
|
||||||
## 쿼리 파라미터
|
|
||||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
|
||||||
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **items**: 노래 목록 (store_name, region, task_id, language, song_result_url, created_at)
|
|
||||||
- **total**: 전체 데이터 수
|
|
||||||
- **page**: 현재 페이지
|
|
||||||
- **page_size**: 페이지당 데이터 수
|
|
||||||
- **total_pages**: 전체 페이지 수
|
|
||||||
- **has_next**: 다음 페이지 존재 여부
|
|
||||||
- **has_prev**: 이전 페이지 존재 여부
|
|
||||||
|
|
||||||
## 사용 예시
|
|
||||||
```
|
|
||||||
GET /songs/?page=1&page_size=10
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- status가 'completed'인 노래만 반환됩니다.
|
|
||||||
- created_at 기준 내림차순 정렬됩니다.
|
|
||||||
""",
|
|
||||||
response_model=PaginatedResponse[SongListItem],
|
|
||||||
responses={
|
|
||||||
200: {"description": "노래 목록 조회 성공"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_songs(
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination_params),
|
|
||||||
) -> PaginatedResponse[SongListItem]:
|
|
||||||
"""완료된 노래 목록을 페이지네이션하여 반환합니다."""
|
|
||||||
print(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}")
|
|
||||||
try:
|
|
||||||
offset = (pagination.page - 1) * pagination.page_size
|
|
||||||
|
|
||||||
# 서브쿼리: task_id별 최신 Song의 id 조회 (completed 상태만)
|
|
||||||
subquery = (
|
|
||||||
select(func.max(Song.id).label("max_id"))
|
|
||||||
.where(Song.status == "completed")
|
|
||||||
.group_by(Song.task_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 전체 개수 조회 (task_id별 최신 1개만)
|
|
||||||
count_query = select(func.count()).select_from(subquery)
|
|
||||||
total_result = await session.execute(count_query)
|
|
||||||
total = total_result.scalar() or 0
|
|
||||||
|
|
||||||
# 데이터 조회 (completed 상태, task_id별 최신 1개만, 최신순)
|
|
||||||
query = (
|
|
||||||
select(Song)
|
|
||||||
.where(Song.id.in_(select(subquery.c.max_id)))
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(pagination.page_size)
|
|
||||||
)
|
|
||||||
result = await session.execute(query)
|
|
||||||
songs = result.scalars().all()
|
|
||||||
|
|
||||||
# Project 정보와 함께 SongListItem으로 변환
|
|
||||||
items = []
|
|
||||||
for song in songs:
|
|
||||||
# Project 조회 (song.project_id 직접 사용)
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project).where(Project.id == song.project_id)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
item = SongListItem(
|
|
||||||
store_name=project.store_name if project else None,
|
|
||||||
region=project.region if project else None,
|
|
||||||
task_id=song.task_id,
|
|
||||||
language=song.language,
|
|
||||||
song_result_url=song.song_result_url,
|
|
||||||
created_at=song.created_at,
|
|
||||||
)
|
|
||||||
items.append(item)
|
|
||||||
|
|
||||||
# 개별 아이템 로그
|
|
||||||
print(
|
|
||||||
f"[get_songs] Item - store_name: {item.store_name}, region: {item.region}, "
|
|
||||||
f"task_id: {item.task_id}, language: {item.language}, "
|
|
||||||
f"song_result_url: {item.song_result_url}, created_at: {item.created_at}"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=pagination.page,
|
|
||||||
page_size=pagination.page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, "
|
|
||||||
f"page_size: {pagination.page_size}, items_count: {len(items)}"
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[get_songs] EXCEPTION - error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"노래 목록 조회에 실패했습니다: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from sqladmin import ModelView
|
from sqladmin import ModelView
|
||||||
|
|
||||||
from app.song.models import Song
|
from app.song.models import Song, SongTimestamp
|
||||||
|
|
||||||
|
|
||||||
class SongAdmin(ModelView, model=Song):
|
class SongAdmin(ModelView, model=Song):
|
||||||
|
|
@ -67,3 +67,59 @@ class SongAdmin(ModelView, model=Song):
|
||||||
"song_result_url": "결과 URL",
|
"song_result_url": "결과 URL",
|
||||||
"created_at": "생성일시",
|
"created_at": "생성일시",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SongTimestampAdmin(ModelView, model=SongTimestamp):
|
||||||
|
name = "노래 타임스탬프"
|
||||||
|
name_plural = "노래 타임스탬프 목록"
|
||||||
|
icon = "fa-solid fa-clock"
|
||||||
|
category = "노래 관리"
|
||||||
|
page_size = 20
|
||||||
|
|
||||||
|
column_list = [
|
||||||
|
"id",
|
||||||
|
"suno_audio_id",
|
||||||
|
"order_idx",
|
||||||
|
"lyric_line",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"created_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
column_details_list = [
|
||||||
|
"id",
|
||||||
|
"suno_audio_id",
|
||||||
|
"order_idx",
|
||||||
|
"lyric_line",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"created_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
form_excluded_columns = ["created_at"]
|
||||||
|
|
||||||
|
column_searchable_list = [
|
||||||
|
SongTimestamp.suno_audio_id,
|
||||||
|
SongTimestamp.lyric_line,
|
||||||
|
]
|
||||||
|
|
||||||
|
column_default_sort = (SongTimestamp.created_at, True)
|
||||||
|
|
||||||
|
column_sortable_list = [
|
||||||
|
SongTimestamp.id,
|
||||||
|
SongTimestamp.suno_audio_id,
|
||||||
|
SongTimestamp.order_idx,
|
||||||
|
SongTimestamp.start_time,
|
||||||
|
SongTimestamp.end_time,
|
||||||
|
SongTimestamp.created_at,
|
||||||
|
]
|
||||||
|
|
||||||
|
column_labels = {
|
||||||
|
"id": "ID",
|
||||||
|
"suno_audio_id": "Suno 오디오 ID",
|
||||||
|
"order_idx": "순서",
|
||||||
|
"lyric_line": "가사",
|
||||||
|
"start_time": "시작 시간",
|
||||||
|
"end_time": "종료 시간",
|
||||||
|
"created_at": "생성일시",
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database.session import get_session
|
|
||||||
|
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
@ -23,9 +23,9 @@ class Song(Base):
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
project_id: 연결된 Project의 id (외래키)
|
project_id: 연결된 Project의 id (외래키)
|
||||||
lyric_id: 연결된 Lyric의 id (외래키)
|
lyric_id: 연결된 Lyric의 id (외래키)
|
||||||
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
|
task_id: 노래 생성 작업의 고유 식별자 (UUID7 형식)
|
||||||
suno_task_id: Suno API 작업 고유 식별자 (선택)
|
suno_task_id: Suno API 작업 고유 식별자 (선택)
|
||||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
status: 처리 상태 (processing, uploading, completed, failed)
|
||||||
song_prompt: 노래 생성에 사용된 프롬프트
|
song_prompt: 노래 생성에 사용된 프롬프트
|
||||||
song_result_url: 생성 결과 URL (선택)
|
song_result_url: 생성 결과 URL (선택)
|
||||||
language: 출력 언어
|
language: 출력 언어
|
||||||
|
|
@ -39,6 +39,10 @@ class Song(Base):
|
||||||
|
|
||||||
__tablename__ = "song"
|
__tablename__ = "song"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
Index("idx_song_task_id", "task_id"),
|
||||||
|
Index("idx_song_project_id", "project_id"),
|
||||||
|
Index("idx_song_lyric_id", "lyric_id"),
|
||||||
|
Index("idx_song_is_deleted", "is_deleted"),
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
"mysql_charset": "utf8mb4",
|
"mysql_charset": "utf8mb4",
|
||||||
|
|
@ -58,7 +62,6 @@ class Song(Base):
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("project.id", ondelete="CASCADE"),
|
ForeignKey("project.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
|
||||||
comment="연결된 Project의 id",
|
comment="연결된 Project의 id",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -66,14 +69,13 @@ class Song(Base):
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("lyric.id", ondelete="CASCADE"),
|
ForeignKey("lyric.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
|
||||||
comment="연결된 Lyric의 id",
|
comment="연결된 Lyric의 id",
|
||||||
)
|
)
|
||||||
|
|
||||||
task_id: Mapped[str] = mapped_column(
|
task_id: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="노래 생성 작업 고유 식별자 (UUID)",
|
comment="노래 생성 작업 고유 식별자 (UUID7)",
|
||||||
)
|
)
|
||||||
|
|
||||||
suno_task_id: Mapped[Optional[str]] = mapped_column(
|
suno_task_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
|
@ -82,10 +84,16 @@ class Song(Base):
|
||||||
comment="Suno API 작업 고유 식별자",
|
comment="Suno API 작업 고유 식별자",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suno_audio_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
nullable=True,
|
||||||
|
comment="Suno 첫번째 노래의 고유 식별자",
|
||||||
|
)
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="처리 상태 (processing, completed, failed)",
|
comment="처리 상태 (processing, uploading, completed, failed)",
|
||||||
)
|
)
|
||||||
|
|
||||||
song_prompt: Mapped[str] = mapped_column(
|
song_prompt: Mapped[str] = mapped_column(
|
||||||
|
|
@ -112,6 +120,13 @@ class Song(Base):
|
||||||
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
comment="소프트 삭제 여부 (True: 삭제됨)",
|
||||||
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
@ -150,3 +165,100 @@ class Song(Base):
|
||||||
f"status='{self.status}'"
|
f"status='{self.status}'"
|
||||||
f")>"
|
f")>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SongTimestamp(Base):
|
||||||
|
"""
|
||||||
|
노래 타임스탬프 테이블
|
||||||
|
|
||||||
|
노래의 가사별 시작/종료 시간 정보를 저장합니다.
|
||||||
|
Suno API에서 반환된 타임스탬프 데이터를 기반으로 생성됩니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: 고유 식별자 (자동 증가)
|
||||||
|
suno_audio_id: 가사의 원본 오디오 ID
|
||||||
|
order_idx: 오디오 내에서 가사의 순서
|
||||||
|
lyric_line: 가사 한 줄의 내용
|
||||||
|
start_time: 가사 시작 시점 (초)
|
||||||
|
end_time: 가사 종료 시점 (초)
|
||||||
|
created_at: 생성 일시 (자동 설정)
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "song_timestamp"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_song_timestamp_suno_audio_id", "suno_audio_id"),
|
||||||
|
Index("idx_song_timestamp_is_deleted", "is_deleted"),
|
||||||
|
{
|
||||||
|
"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="고유 식별자",
|
||||||
|
)
|
||||||
|
|
||||||
|
suno_audio_id: Mapped[str] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
nullable=False,
|
||||||
|
comment="가사의 원본 오디오 ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
order_idx: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
comment="오디오 내에서 가사의 순서",
|
||||||
|
)
|
||||||
|
|
||||||
|
lyric_line: Mapped[str] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=False,
|
||||||
|
comment="가사 한 줄의 내용",
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time: Mapped[float] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=False,
|
||||||
|
comment="가사 시작 시점 (초)",
|
||||||
|
)
|
||||||
|
|
||||||
|
end_time: Mapped[float] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=False,
|
||||||
|
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:
|
||||||
|
def truncate(value: str | None, max_len: int = 10) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "None"
|
||||||
|
return (value[:max_len] + "...") if len(value) > max_len else value
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"<SongTimestamp("
|
||||||
|
f"id={self.id}, "
|
||||||
|
f"suno_audio_id='{truncate(self.suno_audio_id)}', "
|
||||||
|
f"order_idx={self.order_idx}, "
|
||||||
|
f"start_time={self.start_time}, "
|
||||||
|
f"end_time={self.end_time}"
|
||||||
|
f")>"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
from dataclasses import dataclass, field
|
from typing import Optional
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,8 +61,8 @@ class GenerateSongResponse(BaseModel):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"suno_task_id": "abc123...",
|
"song_id": "abc123...",
|
||||||
"message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
|
"message": "노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.",
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,34 +70,40 @@ class GenerateSongResponse(BaseModel):
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"suno_task_id": null,
|
"song_id": null,
|
||||||
"message": "노래 생성 요청에 실패했습니다.",
|
"message": "노래 생성 요청에 실패했습니다.",
|
||||||
"error_message": "Suno API connection error"
|
"error_message": "Suno API connection error"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
|
"song_id": "abc123...",
|
||||||
|
"message": "노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.",
|
||||||
|
"error_message": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
|
"song_id": None,
|
||||||
|
"message": "노래 생성 요청에 실패했습니다.",
|
||||||
|
"error_message": "Suno API connection error",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
success: bool = Field(..., description="요청 성공 여부")
|
success: bool = Field(..., description="요청 성공 여부")
|
||||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
|
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
|
||||||
suno_task_id: Optional[str] = Field(None, description="Suno API 작업 ID")
|
song_id: Optional[str] = Field(None, description="Suno API 작업 ID (상태 조회에 사용)")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
||||||
class PollingSongRequest(BaseModel):
|
|
||||||
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
|
||||||
|
|
||||||
Note:
|
|
||||||
현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용.
|
|
||||||
|
|
||||||
Example Request:
|
|
||||||
{
|
|
||||||
"task_id": "abc123..."
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="Suno 작업 ID")
|
|
||||||
|
|
||||||
|
|
||||||
class SongClipData(BaseModel):
|
class SongClipData(BaseModel):
|
||||||
"""생성된 노래 클립 정보"""
|
"""생성된 노래 클립 정보"""
|
||||||
|
|
||||||
|
|
@ -114,53 +117,62 @@ class SongClipData(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class PollingSongResponse(BaseModel):
|
class PollingSongResponse(BaseModel):
|
||||||
"""노래 생성 상태 조회 응답 스키마
|
"""노래 생성 상태 조회 응답 스키마 (Suno API)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /song/status/{suno_task_id}
|
GET /song/status/{song_id}
|
||||||
Suno API 작업 상태를 조회합니다.
|
Suno API 작업 상태를 조회합니다.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
상태 값:
|
상태 값 (Suno API 응답):
|
||||||
- PENDING: 대기 중
|
- PENDING: Suno API 대기 중
|
||||||
- processing: 생성 중
|
- processing: Suno API에서 노래 생성 중
|
||||||
- SUCCESS / TEXT_SUCCESS / complete: 생성 완료
|
- uploading: MP3 다운로드 및 Azure Blob 업로드 중
|
||||||
- failed: 생성 실패
|
- SUCCESS: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작)
|
||||||
|
- TEXT_SUCCESS: Suno API 노래 생성 완료
|
||||||
|
- failed: Suno API 노래 생성 실패
|
||||||
- error: API 조회 오류
|
- error: API 조회 오류
|
||||||
|
|
||||||
SUCCESS 상태 시:
|
SUCCESS 상태 시:
|
||||||
- 백그라운드에서 MP3 파일 다운로드 시작
|
- 백그라운드에서 MP3 파일 다운로드 및 Azure Blob 업로드 시작
|
||||||
- Song 테이블의 status를 completed로 업데이트
|
- Song 테이블의 status가 uploading으로 변경
|
||||||
- song_result_url에 로컬 파일 경로 저장
|
- 업로드 완료 시 status가 completed로 변경, song_result_url에 Blob URL 저장
|
||||||
|
- completed 상태인 경우 song_result_url 반환
|
||||||
|
|
||||||
|
Example Response (Pending):
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"status": "PENDING",
|
||||||
|
"message": "노래 생성 대기 중입니다.",
|
||||||
|
"error_message": null,
|
||||||
|
"song_result_url": null
|
||||||
|
}
|
||||||
|
|
||||||
Example Response (Processing):
|
Example Response (Processing):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "processing",
|
"status": "processing",
|
||||||
"message": "노래를 생성하고 있습니다.",
|
"message": "노래를 생성하고 있습니다.",
|
||||||
"clips": null,
|
"error_message": null,
|
||||||
"raw_response": {...},
|
"song_result_url": null
|
||||||
"error_message": null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Example Response (Success):
|
Example Response (Uploading):
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"status": "uploading",
|
||||||
|
"message": "노래 생성이 완료되었습니다.",
|
||||||
|
"error_message": null,
|
||||||
|
"song_result_url": null
|
||||||
|
}
|
||||||
|
|
||||||
|
Example Response (Success - Completed):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "SUCCESS",
|
"status": "SUCCESS",
|
||||||
"message": "노래 생성이 완료되었습니다.",
|
"message": "노래 생성이 완료되었습니다.",
|
||||||
"clips": [
|
"error_message": null,
|
||||||
{
|
"song_result_url": "https://blob.azure.com/.../song.mp3"
|
||||||
"id": "clip-id",
|
|
||||||
"audio_url": "https://...",
|
|
||||||
"stream_audio_url": "https://...",
|
|
||||||
"image_url": "https://...",
|
|
||||||
"title": "Song Title",
|
|
||||||
"status": "complete",
|
|
||||||
"duration": 60.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"raw_response": {...},
|
|
||||||
"error_message": null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Example Response (Failure):
|
Example Response (Failure):
|
||||||
|
|
@ -168,207 +180,39 @@ class PollingSongResponse(BaseModel):
|
||||||
"success": false,
|
"success": false,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "상태 조회에 실패했습니다.",
|
"message": "상태 조회에 실패했습니다.",
|
||||||
"clips": null,
|
"error_message": "ConnectionError: ...",
|
||||||
"raw_response": null,
|
"song_result_url": null
|
||||||
"error_message": "ConnectionError: ..."
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"status": "processing",
|
||||||
|
"message": "노래를 생성하고 있습니다.",
|
||||||
|
"error_message": None,
|
||||||
|
"song_result_url": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"message": "노래 생성이 완료되었습니다.",
|
||||||
|
"error_message": None,
|
||||||
|
"song_result_url": "https://blob.azure.com/.../song.mp3",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
success: bool = Field(..., description="조회 성공 여부")
|
success: bool = Field(..., description="조회 성공 여부")
|
||||||
status: Optional[str] = Field(
|
status: Optional[str] = Field(
|
||||||
None, description="작업 상태 (PENDING, processing, SUCCESS, failed)"
|
None,
|
||||||
|
description="작업 상태 (PENDING, processing, uploading, SUCCESS, TEXT_SUCCESS, failed, error)",
|
||||||
)
|
)
|
||||||
message: str = Field(..., description="상태 메시지")
|
message: str = Field(..., description="상태 메시지")
|
||||||
clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록")
|
|
||||||
raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답")
|
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
song_result_url: Optional[str] = Field(
|
||||||
|
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
|
||||||
class SongListItem(BaseModel):
|
|
||||||
"""노래 목록 아이템 스키마
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /songs 응답의 개별 노래 정보
|
|
||||||
|
|
||||||
Example:
|
|
||||||
{
|
|
||||||
"store_name": "스테이 머뭄",
|
|
||||||
"region": "군산",
|
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
|
||||||
"language": "Korean",
|
|
||||||
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
|
||||||
"created_at": "2025-01-15T12:00:00"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
language: Optional[str] = Field(None, description="언어")
|
|
||||||
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadSongResponse(BaseModel):
|
|
||||||
"""노래 다운로드 응답 스키마
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /song/download/{task_id}
|
|
||||||
Polls for song completion and returns project info with song URL.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
상태 값:
|
|
||||||
- processing: 노래 생성 진행 중 (song_result_url은 null)
|
|
||||||
- completed: 노래 생성 완료 (song_result_url 포함)
|
|
||||||
- failed: 노래 생성 실패
|
|
||||||
- not_found: task_id에 해당하는 Song 없음
|
|
||||||
- error: 조회 중 오류 발생
|
|
||||||
|
|
||||||
Example Response (Processing):
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"status": "processing",
|
|
||||||
"message": "노래 생성이 진행 중입니다.",
|
|
||||||
"store_name": null,
|
|
||||||
"region": null,
|
|
||||||
"detail_region_info": null,
|
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
|
||||||
"language": null,
|
|
||||||
"song_result_url": null,
|
|
||||||
"created_at": null,
|
|
||||||
"error_message": null
|
|
||||||
}
|
|
||||||
|
|
||||||
Example Response (Completed):
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"status": "completed",
|
|
||||||
"message": "노래 다운로드가 완료되었습니다.",
|
|
||||||
"store_name": "스테이 머뭄",
|
|
||||||
"region": "군산",
|
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
|
||||||
"language": "Korean",
|
|
||||||
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
|
||||||
"created_at": "2025-01-15T12:00:00",
|
|
||||||
"error_message": null
|
|
||||||
}
|
|
||||||
|
|
||||||
Example Response (Not Found):
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"status": "not_found",
|
|
||||||
"message": "task_id 'xxx'에 해당하는 Song을 찾을 수 없습니다.",
|
|
||||||
"store_name": null,
|
|
||||||
"region": null,
|
|
||||||
"detail_region_info": null,
|
|
||||||
"task_id": null,
|
|
||||||
"language": null,
|
|
||||||
"song_result_url": null,
|
|
||||||
"created_at": null,
|
|
||||||
"error_message": "Song not found"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
success: bool = Field(..., description="다운로드 성공 여부")
|
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
|
||||||
message: str = Field(..., description="응답 메시지")
|
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
|
||||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
|
||||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
|
||||||
language: Optional[str] = Field(None, description="언어")
|
|
||||||
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Dataclass Schemas (Legacy)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StoreData:
|
|
||||||
id: int
|
|
||||||
created_at: datetime
|
|
||||||
store_name: str
|
|
||||||
store_category: str | None = None
|
|
||||||
store_region: str | None = None
|
|
||||||
store_address: str | None = None
|
|
||||||
store_phone_number: str | None = None
|
|
||||||
store_info: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AttributeData:
|
|
||||||
id: int
|
|
||||||
attr_category: str
|
|
||||||
attr_value: str
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SongSampleData:
|
|
||||||
id: int
|
|
||||||
ai: str
|
|
||||||
ai_model: str
|
|
||||||
sample_song: str
|
|
||||||
season: str | None = None
|
|
||||||
num_of_people: int | None = None
|
|
||||||
people_category: str | None = None
|
|
||||||
genre: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PromptTemplateData:
|
|
||||||
id: int
|
|
||||||
prompt: str
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SongFormData:
|
|
||||||
store_name: str
|
|
||||||
store_id: str
|
|
||||||
prompts: str
|
|
||||||
attributes: Dict[str, str] = field(default_factory=dict)
|
|
||||||
attributes_str: str = ""
|
|
||||||
lyrics_ids: List[int] = field(default_factory=list)
|
|
||||||
llm_model: str = "gpt-4o"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def from_form(cls, request: Request):
|
|
||||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
|
||||||
form_data = await request.form()
|
|
||||||
|
|
||||||
# 고정 필드명들
|
|
||||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
|
||||||
|
|
||||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
|
||||||
lyrics_ids = []
|
|
||||||
attributes = {}
|
|
||||||
|
|
||||||
for key, value in form_data.items():
|
|
||||||
if key.startswith("lyrics-"):
|
|
||||||
lyrics_id = key.split("-")[1]
|
|
||||||
lyrics_ids.append(int(lyrics_id))
|
|
||||||
elif key not in fixed_keys:
|
|
||||||
attributes[key] = value
|
|
||||||
|
|
||||||
# attributes를 문자열로 변환
|
|
||||||
attributes_str = (
|
|
||||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
|
||||||
if attributes
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
store_name=form_data.get("store_info_name", ""),
|
|
||||||
store_id=form_data.get("store_id", ""),
|
|
||||||
attributes=attributes,
|
|
||||||
attributes_str=attributes_str,
|
|
||||||
lyrics_ids=lyrics_ids,
|
|
||||||
llm_model=form_data.get("llm_model", "gpt-4o"),
|
|
||||||
prompts=form_data.get("prompts", ""),
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from fastapi.exceptions import HTTPException
|
||||||
from sqlalchemy import Connection, text
|
from sqlalchemy import Connection, text
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
from app.lyrics.schemas.lyrics_schema import (
|
from app.lyrics.schemas.lyrics_schema import (
|
||||||
AttributeData,
|
AttributeData,
|
||||||
PromptTemplateData,
|
PromptTemplateData,
|
||||||
|
|
@ -15,6 +16,8 @@ from app.lyrics.schemas.lyrics_schema import (
|
||||||
)
|
)
|
||||||
from app.utils.chatgpt_prompt import chatgpt_api
|
from app.utils.chatgpt_prompt import chatgpt_api
|
||||||
|
|
||||||
|
logger = get_logger("song")
|
||||||
|
|
||||||
|
|
||||||
async def get_store_info(conn: Connection) -> List[StoreData]:
|
async def get_store_info(conn: Connection) -> List[StoreData]:
|
||||||
try:
|
try:
|
||||||
|
|
@ -38,13 +41,13 @@ async def get_store_info(conn: Connection) -> List[StoreData]:
|
||||||
result.close()
|
result.close()
|
||||||
return all_store_info
|
return all_store_info
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
print(e)
|
logger.error(f"SQLAlchemyError in get_store_info: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
logger.error(f"Unexpected error in get_store_info: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||||
|
|
@ -69,13 +72,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
|
||||||
result.close()
|
result.close()
|
||||||
return all_attribute
|
return all_attribute
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
print(e)
|
logger.error(f"SQLAlchemyError in get_attribute: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
logger.error(f"Unexpected error in get_attribute: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||||
|
|
@ -100,13 +103,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
|
||||||
result.close()
|
result.close()
|
||||||
return all_attribute
|
return all_attribute
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
print(e)
|
logger.error(f"SQLAlchemyError in get_attribute (duplicate): {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
logger.error(f"Unexpected error in get_attribute (duplicate): {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||||
|
|
@ -132,13 +135,13 @@ async def get_sample_song(conn: Connection) -> List[SongSampleData]:
|
||||||
result.close()
|
result.close()
|
||||||
return all_sample_song
|
return all_sample_song
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
print(e)
|
logger.error(f"SQLAlchemyError in get_sample_song: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
logger.error(f"Unexpected error in get_sample_song: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||||
|
|
@ -162,13 +165,13 @@ async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]:
|
||||||
result.close()
|
result.close()
|
||||||
return all_prompt_template
|
return all_prompt_template
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
print(e)
|
logger.error(f"SQLAlchemyError in get_prompt_template: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
logger.error(f"Unexpected error in get_prompt_template: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||||
|
|
@ -192,13 +195,13 @@ async def get_song_result(conn: Connection) -> List[PromptTemplateData]:
|
||||||
result.close()
|
result.close()
|
||||||
return all_prompt_template
|
return all_prompt_template
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
print(e)
|
logger.error(f"SQLAlchemyError in get_song_result (prompt_template): {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
logger.error(f"Unexpected error in get_song_result (prompt_template): {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||||
|
|
@ -210,11 +213,11 @@ async def make_song_result(request: Request, conn: Connection):
|
||||||
# 1. Form 데이터 파싱
|
# 1. Form 데이터 파싱
|
||||||
form_data = await SongFormData.from_form(request)
|
form_data = await SongFormData.from_form(request)
|
||||||
|
|
||||||
print(f"\n{'=' * 60}")
|
logger.info(f"{'=' * 60}")
|
||||||
print(f"Store ID: {form_data.store_id}")
|
logger.info(f"Store ID: {form_data.store_id}")
|
||||||
print(f"Lyrics IDs: {form_data.lyrics_ids}")
|
logger.info(f"Lyrics IDs: {form_data.lyrics_ids}")
|
||||||
print(f"Prompt IDs: {form_data.prompts}")
|
logger.info(f"Prompt IDs: {form_data.prompts}")
|
||||||
print(f"{'=' * 60}\n")
|
logger.info(f"{'=' * 60}")
|
||||||
|
|
||||||
# 2. Store 정보 조회
|
# 2. Store 정보 조회
|
||||||
store_query = """
|
store_query = """
|
||||||
|
|
@ -243,7 +246,7 @@ async def make_song_result(request: Request, conn: Connection):
|
||||||
)
|
)
|
||||||
|
|
||||||
store_info = all_store_info[0]
|
store_info = all_store_info[0]
|
||||||
print(f"Store: {store_info.store_name}")
|
logger.info(f"Store: {store_info.store_name}")
|
||||||
|
|
||||||
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
||||||
|
|
||||||
|
|
@ -251,7 +254,7 @@ async def make_song_result(request: Request, conn: Connection):
|
||||||
combined_sample_song = None
|
combined_sample_song = None
|
||||||
|
|
||||||
if form_data.lyrics_ids:
|
if form_data.lyrics_ids:
|
||||||
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||||
|
|
||||||
lyrics_query = """
|
lyrics_query = """
|
||||||
SELECT sample_song FROM song_sample
|
SELECT sample_song FROM song_sample
|
||||||
|
|
@ -270,11 +273,11 @@ async def make_song_result(request: Request, conn: Connection):
|
||||||
combined_sample_song = "\n\n".join(
|
combined_sample_song = "\n\n".join(
|
||||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||||
)
|
)
|
||||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||||
else:
|
else:
|
||||||
print("샘플 가사가 비어있습니다")
|
logger.info("샘플 가사가 비어있습니다")
|
||||||
else:
|
else:
|
||||||
print("선택된 lyrics가 없습니다")
|
logger.info("선택된 lyrics가 없습니다")
|
||||||
|
|
||||||
# 5. 템플릿 가져오기
|
# 5. 템플릿 가져오기
|
||||||
if not form_data.prompts:
|
if not form_data.prompts:
|
||||||
|
|
@ -283,7 +286,7 @@ async def make_song_result(request: Request, conn: Connection):
|
||||||
detail="프롬프트 ID가 필요합니다",
|
detail="프롬프트 ID가 필요합니다",
|
||||||
)
|
)
|
||||||
|
|
||||||
print("템플릿 가져오기")
|
logger.info("템플릿 가져오기")
|
||||||
|
|
||||||
prompts_query = """
|
prompts_query = """
|
||||||
SELECT * FROM prompt_template WHERE id=:id;
|
SELECT * FROM prompt_template WHERE id=:id;
|
||||||
|
|
@ -310,7 +313,7 @@ async def make_song_result(request: Request, conn: Connection):
|
||||||
)
|
)
|
||||||
|
|
||||||
prompt = prompts_info[0]
|
prompt = prompts_info[0]
|
||||||
print(f"Prompt Template: {prompt.prompt}")
|
logger.debug(f"Prompt Template: {prompt.prompt}")
|
||||||
|
|
||||||
# ✅ 6. 프롬프트 조합
|
# ✅ 6. 프롬프트 조합
|
||||||
updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format(
|
updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format(
|
||||||
|
|
@ -329,7 +332,7 @@ async def make_song_result(request: Request, conn: Connection):
|
||||||
{combined_sample_song}
|
{combined_sample_song}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n")
|
logger.debug(f"[업데이트된 프롬프트]\n{updated_prompt}")
|
||||||
|
|
||||||
# 7. 모델에게 요청
|
# 7. 모델에게 요청
|
||||||
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
|
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
|
||||||
|
|
@ -348,13 +351,12 @@ async def make_song_result(request: Request, conn: Connection):
|
||||||
전체 글자 수 (공백 포함): {total_chars_with_space}자
|
전체 글자 수 (공백 포함): {total_chars_with_space}자
|
||||||
전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}"""
|
전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}"""
|
||||||
|
|
||||||
print("=" * 40)
|
logger.debug("=" * 40)
|
||||||
print("[translate:form_data.attributes_str:] ", form_data.attributes_str)
|
logger.debug(f"[translate:form_data.attributes_str:] {form_data.attributes_str}")
|
||||||
print("[translate:total_chars_with_space:] ", total_chars_with_space)
|
logger.debug(f"[translate:total_chars_with_space:] {total_chars_with_space}")
|
||||||
print("[translate:total_chars_without_space:] ", total_chars_without_space)
|
logger.debug(f"[translate:total_chars_without_space:] {total_chars_without_space}")
|
||||||
print("[translate:final_lyrics:]")
|
logger.debug(f"[translate:final_lyrics:]\n{final_lyrics}")
|
||||||
print(final_lyrics)
|
logger.debug("=" * 40)
|
||||||
print("=" * 40)
|
|
||||||
|
|
||||||
# 8. DB 저장
|
# 8. DB 저장
|
||||||
insert_query = """
|
insert_query = """
|
||||||
|
|
@ -396,9 +398,9 @@ async def make_song_result(request: Request, conn: Connection):
|
||||||
await conn.execute(text(insert_query), insert_params)
|
await conn.execute(text(insert_query), insert_params)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
||||||
print("결과 저장 완료")
|
logger.info("make_song_result 결과 저장 완료")
|
||||||
|
|
||||||
print("\n전체 결과 조회 중...")
|
logger.info("make_song_result 전체 결과 조회 중...")
|
||||||
|
|
||||||
# 9. 생성 결과 가져오기 (created_at 역순)
|
# 9. 생성 결과 가져오기 (created_at 역순)
|
||||||
select_query = """
|
select_query = """
|
||||||
|
|
@ -430,26 +432,20 @@ async def make_song_result(request: Request, conn: Connection):
|
||||||
for row in all_results.fetchall()
|
for row in all_results.fetchall()
|
||||||
]
|
]
|
||||||
|
|
||||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
logger.info(f"make_song_result 전체 {len(results_list)}개의 결과 조회 완료")
|
||||||
|
|
||||||
return results_list
|
return results_list
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
print(f"Database Error: {e}")
|
logger.error(f"make_song_result Database Error: {e}", exc_info=True)
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Unexpected Error: {e}")
|
logger.error(f"make_song_result Unexpected Error: {e}", exc_info=True)
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||||
|
|
@ -490,25 +486,19 @@ async def get_song_result(conn: Connection): # 반환 타입 수정
|
||||||
for row in all_results.fetchall()
|
for row in all_results.fetchall()
|
||||||
]
|
]
|
||||||
|
|
||||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
logger.info(f"get_song_result 전체 {len(results_list)}개의 결과 조회 완료")
|
||||||
|
|
||||||
return results_list
|
return results_list
|
||||||
except HTTPException: # HTTPException은 그대로 raise
|
except HTTPException: # HTTPException은 그대로 raise
|
||||||
raise
|
raise
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
print(f"Database Error: {e}")
|
logger.error(f"get_song_result Database Error: {e}", exc_info=True)
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Unexpected Error: {e}")
|
logger.error(f"get_song_result Unexpected Error: {e}", exc_info=True)
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||||
|
|
@ -520,9 +510,9 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
# 1. Form 데이터 파싱
|
# 1. Form 데이터 파싱
|
||||||
form_data = await SongFormData.from_form(request)
|
form_data = await SongFormData.from_form(request)
|
||||||
|
|
||||||
print(f"\n{'=' * 60}")
|
logger.info(f"{'=' * 60}")
|
||||||
print(f"Store ID: {form_data.store_id}")
|
logger.info(f"make_automation Store ID: {form_data.store_id}")
|
||||||
print(f"{'=' * 60}\n")
|
logger.info(f"{'=' * 60}")
|
||||||
|
|
||||||
# 2. Store 정보 조회
|
# 2. Store 정보 조회
|
||||||
store_query = """
|
store_query = """
|
||||||
|
|
@ -551,7 +541,7 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
)
|
)
|
||||||
|
|
||||||
store_info = all_store_info[0]
|
store_info = all_store_info[0]
|
||||||
print(f"Store: {store_info.store_name}")
|
logger.info(f"make_automation Store: {store_info.store_name}")
|
||||||
|
|
||||||
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
||||||
attribute_query = """
|
attribute_query = """
|
||||||
|
|
@ -596,13 +586,13 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
# 최종 문자열 생성
|
# 최종 문자열 생성
|
||||||
formatted_attributes = "\n".join(formatted_pairs)
|
formatted_attributes = "\n".join(formatted_pairs)
|
||||||
|
|
||||||
print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n")
|
logger.debug(f"[포맷팅된 문자열 속성 정보]\n{formatted_attributes}")
|
||||||
else:
|
else:
|
||||||
print("속성 데이터가 없습니다")
|
logger.info("속성 데이터가 없습니다")
|
||||||
formatted_attributes = ""
|
formatted_attributes = ""
|
||||||
|
|
||||||
# 4. 템플릿 가져오기
|
# 4. 템플릿 가져오기
|
||||||
print("템플릿 가져오기 (ID=1)")
|
logger.info("템플릿 가져오기 (ID=1)")
|
||||||
|
|
||||||
prompts_query = """
|
prompts_query = """
|
||||||
SELECT * FROM prompt_template WHERE id=1;
|
SELECT * FROM prompt_template WHERE id=1;
|
||||||
|
|
@ -624,7 +614,7 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
prompt=row[2],
|
prompt=row[2],
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"Prompt Template: {prompt.prompt}")
|
logger.debug(f"Prompt Template: {prompt.prompt}")
|
||||||
|
|
||||||
# 5. 템플릿 조합
|
# 5. 템플릿 조합
|
||||||
|
|
||||||
|
|
@ -635,17 +625,17 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
description=store_info.store_info or "",
|
description=store_info.store_info or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
logger.debug("=" * 80)
|
||||||
print("업데이트된 프롬프트")
|
logger.debug("업데이트된 프롬프트")
|
||||||
print("=" * 80)
|
logger.debug("=" * 80)
|
||||||
print(updated_prompt)
|
logger.debug(updated_prompt)
|
||||||
print("=" * 80 + "\n")
|
logger.debug("=" * 80)
|
||||||
|
|
||||||
# 4. Sample Song 조회 및 결합
|
# 4. Sample Song 조회 및 결합
|
||||||
combined_sample_song = None
|
combined_sample_song = None
|
||||||
|
|
||||||
if form_data.lyrics_ids:
|
if form_data.lyrics_ids:
|
||||||
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||||
|
|
||||||
lyrics_query = """
|
lyrics_query = """
|
||||||
SELECT sample_song FROM song_sample
|
SELECT sample_song FROM song_sample
|
||||||
|
|
@ -664,14 +654,14 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
combined_sample_song = "\n\n".join(
|
combined_sample_song = "\n\n".join(
|
||||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||||
)
|
)
|
||||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||||
else:
|
else:
|
||||||
print("샘플 가사가 비어있습니다")
|
logger.info("샘플 가사가 비어있습니다")
|
||||||
else:
|
else:
|
||||||
print("선택된 lyrics가 없습니다")
|
logger.info("선택된 lyrics가 없습니다")
|
||||||
|
|
||||||
# 1. song_sample 테이블의 모든 ID 조회
|
# 1. song_sample 테이블의 모든 ID 조회
|
||||||
print("\n[샘플 가사 랜덤 선택]")
|
logger.info("[샘플 가사 랜덤 선택]")
|
||||||
|
|
||||||
all_ids_query = """
|
all_ids_query = """
|
||||||
SELECT id FROM song_sample;
|
SELECT id FROM song_sample;
|
||||||
|
|
@ -679,7 +669,7 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
ids_result = await conn.execute(text(all_ids_query))
|
ids_result = await conn.execute(text(all_ids_query))
|
||||||
all_ids = [row.id for row in ids_result.fetchall()]
|
all_ids = [row.id for row in ids_result.fetchall()]
|
||||||
|
|
||||||
print(f"전체 샘플 가사 개수: {len(all_ids)}개")
|
logger.info(f"전체 샘플 가사 개수: {len(all_ids)}개")
|
||||||
|
|
||||||
# 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체)
|
# 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체)
|
||||||
combined_sample_song = None
|
combined_sample_song = None
|
||||||
|
|
@ -689,7 +679,7 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
sample_count = min(3, len(all_ids))
|
sample_count = min(3, len(all_ids))
|
||||||
selected_ids = random.sample(all_ids, sample_count)
|
selected_ids = random.sample(all_ids, sample_count)
|
||||||
|
|
||||||
print(f"랜덤 선택된 ID: {selected_ids}")
|
logger.debug(f"랜덤 선택된 ID: {selected_ids}")
|
||||||
|
|
||||||
# 3. 선택된 ID로 샘플 가사 조회
|
# 3. 선택된 ID로 샘플 가사 조회
|
||||||
lyrics_query = """
|
lyrics_query = """
|
||||||
|
|
@ -710,11 +700,11 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
combined_sample_song = "\n\n".join(
|
combined_sample_song = "\n\n".join(
|
||||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||||
)
|
)
|
||||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||||
else:
|
else:
|
||||||
print("샘플 가사가 비어있습니다")
|
logger.info("샘플 가사가 비어있습니다")
|
||||||
else:
|
else:
|
||||||
print("song_sample 테이블에 데이터가 없습니다")
|
logger.info("song_sample 테이블에 데이터가 없습니다")
|
||||||
|
|
||||||
# 5. 프롬프트에 샘플 가사 추가
|
# 5. 프롬프트에 샘플 가사 추가
|
||||||
if combined_sample_song:
|
if combined_sample_song:
|
||||||
|
|
@ -726,11 +716,11 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
|
|
||||||
{combined_sample_song}
|
{combined_sample_song}
|
||||||
"""
|
"""
|
||||||
print("샘플 가사 정보가 프롬프트에 추가되었습니다")
|
logger.info("샘플 가사 정보가 프롬프트에 추가되었습니다")
|
||||||
else:
|
else:
|
||||||
print("샘플 가사가 없어 기본 프롬프트만 사용합니다")
|
logger.info("샘플 가사가 없어 기본 프롬프트만 사용합니다")
|
||||||
|
|
||||||
print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n")
|
logger.info(f"[최종 프롬프트 길이: {len(updated_prompt)} 자]")
|
||||||
|
|
||||||
# 7. 모델에게 요청
|
# 7. 모델에게 요청
|
||||||
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
|
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
|
||||||
|
|
@ -763,10 +753,9 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
:sample_song, :result_song, NOW()
|
:sample_song, :result_song, NOW()
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
print("\n[insert_params 선택된 속성 확인]")
|
logger.debug("[insert_params 선택된 속성 확인]")
|
||||||
print(f"Categories: {selected_categories}")
|
logger.debug(f"Categories: {selected_categories}")
|
||||||
print(f"Values: {selected_values}")
|
logger.debug(f"Values: {selected_values}")
|
||||||
print()
|
|
||||||
|
|
||||||
# attr_category, attr_value
|
# attr_category, attr_value
|
||||||
insert_params = {
|
insert_params = {
|
||||||
|
|
@ -783,7 +772,7 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
else "",
|
else "",
|
||||||
"attr_value": ", ".join(selected_values) if selected_values else "",
|
"attr_value": ", ".join(selected_values) if selected_values else "",
|
||||||
"ai": "ChatGPT",
|
"ai": "ChatGPT",
|
||||||
"ai_model": "gpt-4o",
|
"ai_model": "gpt-5-mini",
|
||||||
"genre": "후크송",
|
"genre": "후크송",
|
||||||
"sample_song": combined_sample_song or "없음",
|
"sample_song": combined_sample_song or "없음",
|
||||||
"result_song": final_lyrics,
|
"result_song": final_lyrics,
|
||||||
|
|
@ -792,9 +781,9 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
await conn.execute(text(insert_query), insert_params)
|
await conn.execute(text(insert_query), insert_params)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
||||||
print("결과 저장 완료")
|
logger.info("make_automation 결과 저장 완료")
|
||||||
|
|
||||||
print("\n전체 결과 조회 중...")
|
logger.info("make_automation 전체 결과 조회 중...")
|
||||||
|
|
||||||
# 9. 생성 결과 가져오기 (created_at 역순)
|
# 9. 생성 결과 가져오기 (created_at 역순)
|
||||||
select_query = """
|
select_query = """
|
||||||
|
|
@ -826,26 +815,20 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
for row in all_results.fetchall()
|
for row in all_results.fetchall()
|
||||||
]
|
]
|
||||||
|
|
||||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
logger.info(f"make_automation 전체 {len(results_list)}개의 결과 조회 완료")
|
||||||
|
|
||||||
return results_list
|
return results_list
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
print(f"Database Error: {e}")
|
logger.error(f"make_automation Database Error: {e}", exc_info=True)
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Unexpected Error: {e}")
|
logger.error(f"make_automation Unexpected Error: {e}", exc_info=True)
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||||
|
|
|
||||||
|
|
@ -4,229 +4,139 @@ Song Background Tasks
|
||||||
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.database.session import AsyncSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
from app.utils.common import generate_task_id
|
from app.utils.logger import get_logger
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
from config import prj_settings
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = get_logger("song")
|
||||||
|
|
||||||
|
# HTTP 요청 설정
|
||||||
|
REQUEST_TIMEOUT = 120.0 # 초
|
||||||
|
|
||||||
|
|
||||||
async def download_and_save_song(
|
async def _update_song_status(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
audio_url: str,
|
status: str,
|
||||||
store_name: str,
|
song_url: str | None = None,
|
||||||
) -> None:
|
suno_task_id: str | None = None,
|
||||||
"""백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다.
|
duration: float | None = None,
|
||||||
|
song_id: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Song 테이블의 상태를 업데이트합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: 프로젝트 task_id
|
task_id: 프로젝트 task_id
|
||||||
audio_url: 다운로드할 오디오 URL
|
status: 변경할 상태 ("processing", "completed", "failed")
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
song_url: 노래 URL
|
||||||
|
suno_task_id: Suno task ID (선택)
|
||||||
|
duration: 노래 길이 (선택)
|
||||||
|
song_id: 특정 Song 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 업데이트 성공 여부
|
||||||
"""
|
"""
|
||||||
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
|
||||||
try:
|
try:
|
||||||
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
async with BackgroundSessionLocal() as session:
|
||||||
today = date.today().strftime("%Y-%m-%d")
|
if song_id:
|
||||||
unique_id = await generate_task_id()
|
# song_id로 특정 레코드 조회 (가장 정확한 식별)
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
query_result = await session.execute(
|
||||||
safe_store_name = "".join(
|
select(Song).where(Song.id == song_id)
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
)
|
||||||
).strip()
|
elif suno_task_id:
|
||||||
safe_store_name = safe_store_name or "song"
|
# suno_task_id로 조회 (Suno API 고유 ID)
|
||||||
file_name = f"{safe_store_name}.mp3"
|
query_result = await session.execute(
|
||||||
|
|
||||||
# 절대 경로 생성
|
|
||||||
media_dir = Path("media") / "song" / today / unique_id
|
|
||||||
media_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
file_path = media_dir / file_name
|
|
||||||
print(f"[download_and_save_song] Directory created - path: {file_path}")
|
|
||||||
|
|
||||||
# 오디오 파일 다운로드
|
|
||||||
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(audio_url, timeout=60.0)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async with aiofiles.open(str(file_path), "wb") as f:
|
|
||||||
await f.write(response.content)
|
|
||||||
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
|
||||||
|
|
||||||
# 프론트엔드에서 접근 가능한 URL 생성
|
|
||||||
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
|
||||||
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
|
|
||||||
file_url = f"{base_url}{relative_path}"
|
|
||||||
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
|
||||||
|
|
||||||
# Song 테이블 업데이트 (새 세션 사용)
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
# 여러 개 있을 경우 가장 최근 것 선택
|
|
||||||
result = await session.execute(
|
|
||||||
select(Song)
|
select(Song)
|
||||||
.where(Song.task_id == task_id)
|
.where(Song.suno_task_id == suno_task_id)
|
||||||
.order_by(Song.created_at.desc())
|
.order_by(Song.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if song:
|
|
||||||
song.status = "completed"
|
|
||||||
song.song_result_url = file_url
|
|
||||||
await session.commit()
|
|
||||||
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}, status: completed")
|
|
||||||
else:
|
else:
|
||||||
print(f"[download_and_save_song] Song NOT FOUND in DB - task_id: {task_id}")
|
# 기존 방식: task_id로 최신 레코드 조회 (비권장)
|
||||||
|
query_result = await session.execute(
|
||||||
except Exception as e:
|
|
||||||
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
# 실패 시 Song 테이블 업데이트
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
# 여러 개 있을 경우 가장 최근 것 선택
|
|
||||||
result = await session.execute(
|
|
||||||
select(Song)
|
select(Song)
|
||||||
.where(Song.task_id == task_id)
|
.where(Song.task_id == task_id)
|
||||||
.order_by(Song.created_at.desc())
|
.order_by(Song.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
song = query_result.scalar_one_or_none()
|
||||||
|
|
||||||
if song:
|
if song:
|
||||||
song.status = "failed"
|
song.status = status
|
||||||
|
if song_url is not None:
|
||||||
|
song.song_result_url = song_url
|
||||||
|
if duration is not None:
|
||||||
|
song.duration = duration
|
||||||
await session.commit()
|
await session.commit()
|
||||||
print(f"[download_and_save_song] FAILED - task_id: {task_id}, status updated to failed")
|
logger.info(f"[Song] Status updated - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, status: {status}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, error: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def download_and_upload_song_to_blob(
|
async def _download_audio(url: str, task_id: str) -> bytes:
|
||||||
task_id: str,
|
"""URL에서 오디오 파일을 다운로드합니다.
|
||||||
audio_url: str,
|
|
||||||
store_name: str,
|
|
||||||
) -> None:
|
|
||||||
"""백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: 프로젝트 task_id
|
url: 다운로드할 URL
|
||||||
audio_url: 다운로드할 오디오 URL
|
task_id: 로그용 task_id
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
|
||||||
|
Returns:
|
||||||
|
bytes: 다운로드한 파일 내용
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: 다운로드 실패 시
|
||||||
"""
|
"""
|
||||||
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
logger.info(f"[Download] Downloading - task_id: {task_id}")
|
||||||
temp_file_path: Path | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
|
||||||
safe_store_name = "".join(
|
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
|
||||||
).strip()
|
|
||||||
safe_store_name = safe_store_name or "song"
|
|
||||||
file_name = f"{safe_store_name}.mp3"
|
|
||||||
|
|
||||||
# 임시 저장 경로 생성
|
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
temp_file_path = temp_dir / file_name
|
|
||||||
print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
|
|
||||||
|
|
||||||
# 오디오 파일 다운로드
|
|
||||||
print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(audio_url, timeout=60.0)
|
response = await client.get(url, timeout=REQUEST_TIMEOUT)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
logger.info(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
||||||
await f.write(response.content)
|
return response.content
|
||||||
print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
|
||||||
|
|
||||||
# Azure Blob Storage에 업로드
|
|
||||||
uploader = AzureBlobUploader(task_id=task_id)
|
|
||||||
upload_success = await uploader.upload_music(file_path=str(temp_file_path))
|
|
||||||
|
|
||||||
if not upload_success:
|
|
||||||
raise Exception("Azure Blob Storage 업로드 실패")
|
|
||||||
|
|
||||||
# SAS 토큰이 제외된 public_url 사용
|
|
||||||
blob_url = uploader.public_url
|
|
||||||
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
|
||||||
|
|
||||||
# Song 테이블 업데이트 (새 세션 사용)
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
# 여러 개 있을 경우 가장 최근 것 선택
|
|
||||||
result = await session.execute(
|
|
||||||
select(Song)
|
|
||||||
.where(Song.task_id == task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if song:
|
|
||||||
song.status = "completed"
|
|
||||||
song.song_result_url = blob_url
|
|
||||||
await session.commit()
|
|
||||||
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}, status: completed")
|
|
||||||
else:
|
|
||||||
print(f"[download_and_upload_song_to_blob] Song NOT FOUND in DB - task_id: {task_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
# 실패 시 Song 테이블 업데이트
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Song)
|
|
||||||
.where(Song.task_id == task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if song:
|
|
||||||
song.status = "failed"
|
|
||||||
await session.commit()
|
|
||||||
print(f"[download_and_upload_song_to_blob] FAILED - task_id: {task_id}, status updated to failed")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# 임시 파일 삭제
|
|
||||||
if temp_file_path and temp_file_path.exists():
|
|
||||||
try:
|
|
||||||
temp_file_path.unlink()
|
|
||||||
print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
|
|
||||||
|
|
||||||
# 임시 디렉토리 삭제 시도
|
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
|
||||||
if temp_dir.exists():
|
|
||||||
try:
|
|
||||||
temp_dir.rmdir()
|
|
||||||
except Exception:
|
|
||||||
pass # 디렉토리가 비어있지 않으면 무시
|
|
||||||
|
|
||||||
|
|
||||||
async def download_and_upload_song_by_suno_task_id(
|
async def download_and_upload_song_by_suno_task_id(
|
||||||
suno_task_id: str,
|
suno_task_id: str,
|
||||||
audio_url: str,
|
audio_url: str,
|
||||||
store_name: str,
|
user_uuid: str,
|
||||||
duration: float | None = None,
|
duration: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
||||||
|
|
||||||
|
파일명은 suno_task_id를 사용하여 고유성을 보장합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
suno_task_id: Suno API 작업 ID
|
suno_task_id: Suno API 작업 ID (파일명으로도 사용)
|
||||||
audio_url: 다운로드할 오디오 URL
|
audio_url: 다운로드할 오디오 URL
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
||||||
duration: 노래 재생 시간 (초)
|
duration: 노래 재생 시간 (초)
|
||||||
"""
|
"""
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
|
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, duration: {duration}")
|
||||||
temp_file_path: Path | None = None
|
temp_file_path: Path | None = None
|
||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# suno_task_id로 Song 조회하여 task_id 가져오기
|
# suno_task_id로 Song 조회하여 task_id 가져오기
|
||||||
async with AsyncSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Song)
|
select(Song)
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
.where(Song.suno_task_id == suno_task_id)
|
||||||
|
|
@ -236,37 +146,33 @@ async def download_and_upload_song_by_suno_task_id(
|
||||||
song = result.scalar_one_or_none()
|
song = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not song:
|
if not song:
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
|
logger.warning(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
task_id = song.task_id
|
task_id = song.task_id
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
||||||
|
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
# suno_task_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요)
|
||||||
safe_store_name = "".join(
|
file_name = f"{suno_task_id}.mp3"
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
|
||||||
).strip()
|
|
||||||
safe_store_name = safe_store_name or "song"
|
|
||||||
file_name = f"{safe_store_name}.mp3"
|
|
||||||
|
|
||||||
# 임시 저장 경로 생성
|
# 임시 저장 경로 생성
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
temp_file_path = temp_dir / file_name
|
temp_file_path = temp_dir / file_name
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
|
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
|
||||||
|
|
||||||
# 오디오 파일 다운로드
|
# 오디오 파일 다운로드
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
|
logger.info(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(audio_url, timeout=60.0)
|
content = await _download_audio(audio_url, task_id)
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||||
await f.write(response.content)
|
await f.write(content)
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
|
|
||||||
|
logger.info(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
|
||||||
|
|
||||||
# Azure Blob Storage에 업로드
|
# Azure Blob Storage에 업로드
|
||||||
uploader = AzureBlobUploader(task_id=task_id)
|
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
|
||||||
upload_success = await uploader.upload_music(file_path=str(temp_file_path))
|
upload_success = await uploader.upload_music(file_path=str(temp_file_path))
|
||||||
|
|
||||||
if not upload_success:
|
if not upload_success:
|
||||||
|
|
@ -274,54 +180,41 @@ async def download_and_upload_song_by_suno_task_id(
|
||||||
|
|
||||||
# SAS 토큰이 제외된 public_url 사용
|
# SAS 토큰이 제외된 public_url 사용
|
||||||
blob_url = uploader.public_url
|
blob_url = uploader.public_url
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
|
logger.info(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
|
||||||
|
|
||||||
# Song 테이블 업데이트 (새 세션 사용)
|
# Song 테이블 업데이트
|
||||||
async with AsyncSessionLocal() as session:
|
await _update_song_status(
|
||||||
result = await session.execute(
|
task_id=task_id,
|
||||||
select(Song)
|
status="completed",
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
song_url=blob_url,
|
||||||
.order_by(Song.created_at.desc())
|
suno_task_id=suno_task_id,
|
||||||
.limit(1)
|
duration=duration,
|
||||||
)
|
)
|
||||||
song = result.scalar_one_or_none()
|
logger.info(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
|
||||||
|
|
||||||
if song:
|
except httpx.HTTPError as e:
|
||||||
song.status = "completed"
|
logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
|
||||||
song.song_result_url = blob_url
|
if task_id:
|
||||||
if duration is not None:
|
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||||
song.duration = duration
|
|
||||||
await session.commit()
|
except SQLAlchemyError as e:
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, status: completed, duration: {duration}")
|
logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
|
||||||
else:
|
if task_id:
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND in DB - suno_task_id: {suno_task_id}")
|
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
|
||||||
# 실패 시 Song 테이블 업데이트
|
|
||||||
if task_id:
|
if task_id:
|
||||||
async with AsyncSessionLocal() as session:
|
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||||
result = await session.execute(
|
|
||||||
select(Song)
|
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if song:
|
|
||||||
song.status = "failed"
|
|
||||||
await session.commit()
|
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] FAILED - suno_task_id: {suno_task_id}, status updated to failed")
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 임시 파일 삭제
|
# 임시 파일 삭제
|
||||||
if temp_file_path and temp_file_path.exists():
|
if temp_file_path and temp_file_path.exists():
|
||||||
try:
|
try:
|
||||||
temp_file_path.unlink()
|
temp_file_path.unlink()
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
|
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
|
logger.warning(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
|
||||||
|
|
||||||
# 임시 디렉토리 삭제 시도
|
# 임시 디렉토리 삭제 시도
|
||||||
if task_id:
|
if task_id:
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue