Compare commits

..

149 Commits

Author SHA1 Message Date
jaehwang 219e7ed7c0 오탈자 처리 2026-02-23 00:55:35 +00:00
jaehwang a876825f82 merge main 2026-02-20 08:28:43 +00:00
jaehwang a4db70c2e6 seo 경로 변경 2026-02-20 08:25:11 +00:00
jaehwang 172586e699 유튜브 자동 seo description redis 적용 2026-02-20 08:19:45 +00:00
jaehwang b3354d4ad1 유튜브 SEO 설명 추가 PoC 2026-02-20 01:11:57 +00:00
Dohyun Lim 1398546dac add logs for token 2026-02-13 17:41:27 +09:00
hbyang 9d074632bc margeting_inteligence -> marketing_intelligence 오타 수정 . 2026-02-13 10:09:02 +09:00
Dohyun Lim 7f0ae81351 remove endpoint at the video of get_videos 2026-02-12 17:52:51 +09:00
Dohyun Lim c89e510c98 Merge branch 'refresh'
리프레쉬 토큰 관련 기능 병합
2026-02-12 17:20:31 +09:00
Dohyun Lim ab8d362aa0 finished test for refresh token 2026-02-12 17:19:16 +09:00
hbyang 157d1b1ad9 서버 아키텍쳐 docs 커밋 . 2026-02-12 14:35:55 +09:00
jaehwang f1dd675ecb Merge branch 'main' into youtube-description 2026-02-12 05:10:32 +00:00
jaehwang ada5dfeeb4 가사 marketing Intel 삽입 2026-02-12 05:10:08 +00:00
jaehwang 18635d7995 update crawler retry and timeout setting 2026-02-11 07:09:34 +00:00
jaehwang 54e66e4682 add marketing intelligence db 2026-02-11 05:21:00 +00:00
Dohyun Lim bc2342163f merged get_videos 2026-02-11 11:17:59 +09:00
Dohyun Lim 34e0cada48 update get_videos 2026-02-10 14:58:45 +09:00
hbyang 4e87c76b35 timezone auth 비교 수정 . 2026-02-10 13:54:22 +09:00
hbyang bc777ba66c refresh token 수정 . 2026-02-09 16:53:15 +09:00
hbyang e29e10eb29 youtube bug fix, timezone 수정, lazyloading 수정 . 2026-02-09 13:15:20 +09:00
hbyang 40afe9392c Merge remote-tracking branch 'origin/main' - resolve conflict
- Resolved conflict in app/social/services.py
- Kept token auto-refresh logic with now() function for timezone support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 11:04:14 +09:00
hbyang 325fb9af69 social 계정 연동 refresh 기능 추가 . 2026-02-09 10:59:44 +09:00
Dohyun Lim e19c8c9d62 clean docs 2026-02-06 16:21:32 +09:00
Dohyun Lim 369e572b0a Merge branch 'timezone' 2026-02-06 15:17:51 +09:00
Dohyun Lim f6ce81e14e bugfix for suno error msg 2026-02-06 10:59:02 +09:00
dhlim 32c6c210a0 가사 템플릿 업데이트, 폰트 선택 기능 추가, 주소 injection업데이트 2026-02-04 10:28:47 +00:00
Dohyun Lim c207b8a48f update .env 2026-02-04 17:22:42 +09:00
Dohyun Lim 0d34aa7f99 remove duplicate index at token table 2026-02-04 16:58:50 +09:00
Dohyun Lim dd16013816 first commit 2026-02-04 16:35:08 +09:00
dhlim 9d92b5d42c timeout 시 한번 재시도 2026-02-04 02:21:17 +00:00
dhlim f24ff46b09 크롤링 디버그 로그 추가 2026-02-04 01:05:59 +00:00
hbyang 89ea0c783e db sql문 추가 . 2026-02-03 16:24:49 +09:00
dhlim c568f949c7 해시태그 #출력 제거(프롬프트), 자동완성 크롤링 내부 에러 발생 시 500출력, 재시도 로직 추가, 타임아웃 시간 30초로 증가 2026-02-03 06:45:16 +00:00
dhlim 96597dd555 fix pyproject.toml 2026-02-03 05:21:09 +00:00
jaehwang 5a77d22c9f scalar 추가, 프롬프트 최적화, 파이썬 3.13.11버전 고정 2026-02-03 05:11:40 +00:00
jaehwang f208e93420 셀링 포인트 폴리곤 최하 70점 제한 제거, category 영어와 한국어로 분화 2026-02-03 04:44:19 +00:00
Dohyun Lim 08a699648d add time 2026-02-02 19:00:54 +09:00
Dohyun Lim 29dd08081b fix bugs 2026-02-02 17:44:09 +09:00
Dohyun Lim 2cb9d67a70 merge with insta 2026-02-02 17:15:50 +09:00
hbyang e89709ce87 upload bug fix . 2026-02-02 17:10:35 +09:00
Dohyun Lim ca7c0858e2 modify suno file name 2026-02-02 17:01:18 +09:00
jaehwang e1386b891e Merge branch 'main' into creatomate 2026-02-02 07:58:19 +00:00
hbyang 8c7893d989 youtube upload 기능 작성 . 2026-02-02 16:42:38 +09:00
Dohyun Lim eff711e03e finished test for instagram 2026-02-02 16:41:51 +09:00
jaehwang f97ecb29e9 fix missing argument 2026-02-02 07:05:27 +00:00
Dohyun Lim 08d47a6990 modify set ver1 2026-02-02 15:30:26 +09:00
jaehwang 5700965fae 비디오 automated lyric 가사 (animated)추가 2026-02-02 14:17:01 +09:00
hbyang ef203dc14d youtube 계정 연결 작업 완료 2026-02-02 11:13:08 +09:00
Dohyun Lim 19bd12d581 add sns endpoint 2026-02-02 10:36:42 +09:00
bluebamus 18b18e9ff2 finish poc 2026-02-01 20:43:53 +09:00
bluebamus f73be9c6d0 Merge branch 'main' into insta 2026-01-31 22:51:55 +09:00
hbyang c92d6e2135 fix: 가사/노래/영상 재생성 시 올바른 레코드 업데이트되도록 수정 2026-01-30 15:19:26 +09:00
jaehwang 7a0d5a6272 fix lyric pydantic bug 2026-01-30 01:56:51 +00:00
jaehwang b8ae598460 lyric 마케팅 인텔리전스 optional 패티 2026-01-30 01:38:16 +00:00
hbyang d40e2bd430 Merge branch 'main' of https://gitea.o2o.kr/castad/o2o-castad-backend 2026-01-30 10:17:22 +09:00
hbyang f153157227 jwt token timezone 변경 . 2026-01-30 10:13:50 +09:00
jaehwang abd098e4c0 remove report and old output format 2026-01-30 00:34:38 +00:00
Dohyun Lim f4821bf157 finish poc 2026-01-29 16:25:16 +09:00
jaehwang 2e27eb5742 Merge branch 'main' into prompt 2026-01-29 07:07:03 +00:00
hbyang 1cb698e8ea 자동완성 기능 추가 . 2026-01-29 16:03:25 +09:00
hbyang 51c4ea7552 gitignore 수정 . 2026-01-29 13:25:48 +09:00
jaehwang fd4d85cf9e 마케팅 프롬프트 변경 2026-01-29 02:24:08 +00:00
jaehwang d259740d97 change home output 2026-01-29 00:12:56 +00:00
jaehwang db853e6604 Merge branch 'main' into prompt 2026-01-28 23:43:11 +00:00
jaehwang e32e795c73 프롬프트 아웃풋 description 추가 2026-01-28 23:41:33 +00:00
Dohyun Lim 247a9f3322 merge with my-archive 2026-01-28 19:41:18 +09:00
Dohyun Lim c07a2f6dae finish 2026-01-28 19:23:46 +09:00
jaehwang df3bfda594 merge main 2026-01-28 16:59:24 +09:00
jaehwang 7f5a75e0a5 change prompt format 2026-01-28 16:44:51 +09:00
Dohyun Lim 32ae5530b6 uuid7으로 필드 및 처리관련 수정 2026-01-28 16:35:08 +09:00
Dohyun Lim aa8d9d7c14 사용자의 uuid -> ksuid로 변경, 테이블 구조 변경 2026-01-28 14:05:15 +09:00
Dohyun Lim dc7351d0f9 merge main 2026-01-28 10:49:27 +09:00
Dohyun Lim e30e7304df finish api exception handling 2026-01-28 10:33:35 +09:00
Dohyun Lim 1665d11d66 openapi exception, retry, timeout finished 2026-01-27 16:34:48 +09:00
Dohyun Lim c7b77fb532 Merge main into retry: main 최신 코드 병합 2026-01-27 15:36:51 +09:00
Dohyun Lim fea30e79fd ChatGPT API timeout 및 retry 설정 추가 2026-01-27 15:28:44 +09:00
jaehwang 0e1eae75dd main 업데이트 2026-01-27 11:39:21 +09:00
jaehwang 2e9a43263f 자동완성 scalar docs 일부 수정 2026-01-27 00:43:46 +00:00
jaehwang 3039a65ee4 pw 종속성 추가 및 버그 수정, *주의: docker compose 파일 변경됨 2026-01-27 00:06:37 +00:00
jaehwang f29ac29649 자동완성 기능 추가 2026-01-26 16:59:13 +09:00
jaehwang fc88eedfa2 fix forgoten merge conflict 2026-01-26 15:10:13 +09:00
jaehwang 72dcd09771 update main 2026-01-26 15:07:56 +09:00
Dohyun Lim 6d2961cee2 Remove the song endpoint and send the song URL when pulling if the status is SUCCESS. 2026-01-26 11:54:47 +09:00
bluebamus b48d218a1d clean project structure 2026-01-24 00:18:03 +09:00
Dohyun Lim 7e2646337f 수노 타임아웃 증가 2026-01-23 18:26:58 +09:00
Dohyun Lim 2ac4b75d96 bug fix for url path 2026-01-23 18:04:40 +09:00
Dohyun Lim 1e16e0e3eb 데모버전 안정화 2026-01-23 17:05:44 +09:00
jaehwang 88a91aa6d7 Merge branch 'main' into prompt 2026-01-23 13:11:55 +09:00
Dohyun Lim f6da65044a update code 2026-01-22 11:57:56 +09:00
Dohyun Lim 4a06bfdde4 modify song process flow 2026-01-22 11:43:13 +09:00
Dohyun Lim 7038faaf74 add loggers for kakao login 2026-01-21 17:24:07 +09:00
Dohyun Lim 219d8798ad modify import error 2026-01-21 17:12:09 +09:00
Dohyun Lim cea23efac3 update redirect path format 2026-01-21 16:35:48 +09:00
jaehwang a6daff4e38 Merge branch 'main' into scraper-poc 2026-01-21 06:26:59 +00:00
jaehwang 8ae2a68ae4 자막 템플릿 조정 2026-01-21 06:25:53 +00:00
hbyang 72e06ee951 카카오 로그인 redirect 처리 . 2026-01-21 14:59:55 +09:00
jaehwang bcd2c0a96f Merge branch 'main' into scraper-poc 2026-01-21 04:59:16 +00:00
jaehwang 198513f237 creatomate 가사 정합 2026-01-21 04:18:14 +00:00
Dohyun Lim 36de908431 add kakao_verify endpoint for kakao login at frontend side 2026-01-21 08:53:16 +09:00
Dohyun Lim b4e5d04dbb update tags_metadata at main.py 2026-01-20 18:32:08 +09:00
Dohyun Lim b6e50be9ca update router tags 2026-01-20 17:41:21 +09:00
Dohyun Lim 47aca58b02 add redirect_url at kakao_login 2026-01-20 17:02:26 +09:00
jaehwang 94be8a0746 upload db on timestamped lyric 2026-01-20 15:11:03 +09:00
jaehwang da59f3d6e3 update main 2026-01-20 14:52:35 +09:00
Dohyun Lim ee6069e5d5 feat: update song model and related routers, schemas, worker 2026-01-20 14:40:58 +09:00
Dohyun Lim ece201f92b update README.md 2026-01-20 11:26:13 +09:00
Dohyun Lim 56069a04a1 modify url parameter 2026-01-19 16:59:25 +09:00
Dohyun Lim f362effe7a fix callback style 2026-01-19 15:05:11 +09:00
jaehwang 38323870ec (WIP) prompt schema change 2026-01-19 14:42:31 +09:00
Dohyun Lim 1562aee998 first commit 2026-01-19 13:56:48 +09:00
jaehwang 2f384fb72a merge main 2026-01-16 07:43:19 +00:00
Ubuntu 4c47d6e0fc fix some prompt 2026-01-16 06:55:48 +00:00
jaehwang 4e15e44cbe 프롬프트 처리 구조 변경 및 마케팅/가사 프롬프트 최신화 2026-01-16 10:05:29 +09:00
Dohyun Lim a9d0a3ee7f added auth 2026-01-15 17:33:57 +09:00
Dohyun Lim a3d3c75463 added .cluade 2026-01-15 16:16:05 +09:00
Dohyun Lim f5130b73d7 added userproject table 2026-01-15 13:55:43 +09:00
Dohyun Lim d7120bb0ba finish user model definition 2026-01-15 13:09:29 +09:00
Dohyun Lim bf7b53c8e8 add logger 2026-01-14 17:46:45 +09:00
Dohyun Lim 1acd8846ab fix lyric 2026-01-13 17:27:30 +09:00
jaehwang ba26284451 Merge branch 'main' into scraper-poc 2026-01-13 15:50:58 +09:00
Dohyun Lim 3f75b6d61d add facilities from result of crawling 2026-01-12 16:50:16 +09:00
jaehwang 2e1ccebe43 테스트 케이스 추가 및 1차 시도 실패시 2차 시도 2026-01-12 14:55:48 +09:00
Dohyun Lim b84c07c325 update toml 2026-01-12 14:29:50 +09:00
Dohyun Lim 94aae50564 update crawler for short url 2026-01-12 13:46:28 +09:00
jaehwang b7edba8c80 Playwright 모듈 PoC 추가 2026-01-12 11:01:03 +09:00
Dohyun Lim 2b777f5314 remove .gitkeep 2026-01-09 10:31:30 +09:00
Dohyun Lim 1199eca649 upgrade marketing template 2026-01-09 10:30:03 +09:00
Dohyun Lim 073777081e add logs for tracing processing task 2026-01-08 14:05:44 +09:00
jaehwang 56d4c690bf Merge branch 'main' into scraper-poc 2026-01-02 10:18:02 +09:00
bluebamus efddee217a 개선된 pool 관리 2025-12-30 01:01:05 +09:00
bluebamus 8671a45d96 bug fix for 다중 쿼리 2025-12-30 00:01:20 +09:00
bluebamus 5c99610e00 세션 및 비동기 처리 개선 2025-12-29 23:46:21 +09:00
bluebamus 153b9f0ca4 비동기 적용 2025-12-29 16:48:01 +09:00
bluebamus 95d90dcb50 영상 생성시 이미지 url 전송 -> task_id로 직접 검색으로 변경 2025-12-29 12:15:46 +09:00
bluebamus c6d9edbb42 이미지 업로드 때 task_id 생성으로 변경, 가사 생성시 task_id 받아오는 것으로 변경 2025-12-29 11:01:36 +09:00
bluebamus f81d158f0f add docs 2025-12-28 17:54:26 +09:00
bluebamus c6a2fa6808 비디오 영상 생성 요청시, 가사 전달 하는 항목 삭제, task_id로 직접 검색 2025-12-27 13:44:59 +09:00
bluebamus 47da24a12e 비디오 영상 생성 요청 버그 픽스 2025-12-26 18:56:55 +09:00
bluebamus d4bce083ab buf fix 2025-12-26 18:45:07 +09:00
bluebamus 5dddbaeda2 duration 버그 픽스 2025-12-26 18:21:22 +09:00
bluebamus 3bfb5c81b6 완성 2025-12-26 17:50:46 +09:00
bluebamus 52520d770b 크레아토 완료 2025-12-26 17:20:36 +09:00
bluebamus 586dd5ccc9 노래 생성 후 blob 업로드 완료 2025-12-26 16:02:14 +09:00
bluebamus 62dd681b83 이미지 업로드 관련 디버그 프린트 삭제 2025-12-26 15:45:35 +09:00
bluebamus 266a51fe1d 이미지 blob에 업로드 완료 2025-12-26 15:34:37 +09:00
jaehwang 3432d5189b 블롭스토리지 class 추가 2025-12-26 15:30:51 +09:00
bluebamus 12e6f7357c blob 이미지 업로드 완료 2025-12-26 15:25:13 +09:00
bluebamus 6917a76d60 finished upload images 2025-12-26 13:27:39 +09:00
jaehwang 1516f2807c 스크래퍼 업데이트: 단축 url 처리, 시설 정보 추출 2025-12-26 11:10:14 +09:00
212 changed files with 47845 additions and 13623 deletions

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

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

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

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

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

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

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

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

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

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

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

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

25
.gitignore vendored
View File

@ -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

View File

@ -1 +1 @@
3.13 3.13.11

53
CLAUDE.md Normal file
View File

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

202
README.md
View File

@ -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 토큰 발급 │ │ │
│◀───────────────│ │ │
│ │ │ │
```

View File

@ -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

View File

View File

View File

View File

@ -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)}",
)

View File

0
app/archive/models.py Normal file
View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

@ -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

View File

@ -4,12 +4,16 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from app.utils.logger import get_logger
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 적용)

View File

@ -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}",
}
) )

View File

@ -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)))

View File

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

View File

@ -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(
) )
# =============================================================================
# 백그라운드 태스크 전용 엔진 (메인 풀과 분리)
# =============================================================================
background_engine = create_async_engine(
url=db_settings.MYSQL_URL,
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_timeout": 10,
"charset": "utf8mb4",
},
)
# 백그라운드 세션 팩토리
BackgroundSessionLocal = async_sessionmaker(
bind=background_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def create_db_tables(): async def create_db_tables():
import asyncio import asyncio
# 모델 import (테이블 메타데이터 등록용) # 모델 import (테이블 메타데이터 등록용)
from app.home.models import Image, Project # noqa: F401 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.lyric.models import Lyric # noqa: F401
from app.song.models import Song # noqa: F401 from app.song.models import Song, SongTimestamp # noqa: F401
from app.video.models import Video # 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
print("Creating database tables...") # 생성할 테이블 목록
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 asyncio.timeout(10):
async with engine.begin() as connection: async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all) await connection.run_sync(
lambda conn: Base.metadata.create_all(conn, tables=tables_to_create)
)
# FastAPI 의존성용 세션 제너레이터 # FastAPI 의존성용 세션 제너레이터
async def get_session() -> AsyncGenerator[AsyncSession, None]: 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: 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
# print("Session commited")
# await session.commit()
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
print(f"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
# async with 종료 시 session.close()가 자동 호출됨 finally:
total_time = time.perf_counter() - start_time
# logger.debug(
# f"[get_background_session] RELEASE - "
# f"duration: {total_time*1000:.1f}ms, "
# f"pool_out: {pool.checkedout()}"
# )
# 앱 종료 시 엔진 리소스 정리 함수 # 앱 종료 시 엔진 리소스 정리 함수
async def dispose_engine() -> None: async def dispose_engine() -> None:
logger.info("[dispose_engine] Disposing database engines...")
await engine.dispose() await engine.dispose()
print("Database engine disposed") logger.info("[dispose_engine] Main engine disposed")
await background_engine.dispose()
logger.info("[dispose_engine] Background engine disposed - ALL DONE")
# =============================================================================
# 백그라운드 태스크용 세션 (별도 이벤트 루프에서 사용)
# =============================================================================
@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,
poolclass=NullPool,
connect_args={
"connect_timeout": 3,
"charset": "utf8mb4",
},
)
session_factory = async_sessionmaker(
bind=worker_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async with session_factory() as session:
try:
yield session
except Exception as e:
await session.rollback()
print(f"Worker session rollback due to: {e}")
raise e
finally:
await session.close()
await worker_engine.dispose()

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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,

View File

@ -1,161 +0,0 @@
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
class AttributeInfo(BaseModel):
"""음악 속성 정보"""
genre: str = Field(..., description="음악 장르")
vocal: str = Field(..., description="보컬 스타일")
tempo: str = Field(..., description="템포")
mood: str = Field(..., description="분위기")
class GenerateRequestImg(BaseModel):
"""이미지 URL 스키마"""
url: str = Field(..., description="이미지 URL")
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class GenerateRequestInfo(BaseModel):
"""생성 요청 정보 스키마 (이미지 제외)"""
customer_name: str = Field(..., description="고객명/가게명")
region: str = Field(..., description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
language: str = Field(
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateRequest(GenerateRequestInfo):
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
이미지 없이 프로젝트 정보만 전달합니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": {
"genre": "K-Pop",
"vocal": "Raspy",
"tempo": "110 BPM",
"mood": "happy",
},
"language": "Korean",
}
}
)
class GenerateUrlsRequest(GenerateRequestInfo):
"""URL 기반 생성 요청 스키마 (JSON body)
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": {
"genre": "K-Pop",
"vocal": "Raspy",
"tempo": "110 BPM",
"mood": "happy",
},
"language": "Korean",
"images": [
{"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
],
}
}
)
images: list[GenerateRequestImg] = Field(
..., description="이미지 URL 목록", min_length=1
)
class GenerateUploadResponse(BaseModel):
"""파일 업로드 기반 생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
message: str = Field(..., description="응답 메시지")
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
class GenerateResponse(BaseModel):
"""생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
message: str = Field(..., description="응답 메시지")
class CrawlingRequest(BaseModel):
"""크롤링 요청 스키마"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
}
}
)
url: str = Field(..., description="네이버 지도 장소 URL")
class ProcessedInfo(BaseModel):
"""가공된 장소 정보 스키마"""
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
class MarketingAnalysis(BaseModel):
"""마케팅 분석 결과 스키마"""
report: str = Field(..., description="마케팅 분석 리포트")
tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
class CrawlingResponse(BaseModel):
"""크롤링 응답 스키마"""
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
image_count: int = Field(..., description="이미지 개수")
processed_info: Optional[ProcessedInfo] = Field(
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
)
marketing_analysis: Optional[MarketingAnalysis] = Field(
None, description="마케팅 분석 결과 (report, tags, facilities)"
)
class ErrorResponse(BaseModel):
"""에러 응답 스키마"""
success: bool = Field(default=False, description="요청 성공 여부")
error_code: str = Field(..., description="에러 코드")
message: str = Field(..., description="에러 메시지")
detail: Optional[str] = Field(None, description="상세 에러 정보")

View File

@ -0,0 +1,329 @@
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from app.utils.prompts.schemas import MarketingPromptOutput
class CrawlingRequest(BaseModel):
"""크롤링 요청 스키마"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
}
}
)
url: str = Field(..., description="네이버 지도 장소 URL")
class AutoCompleteRequest(BaseModel):
"""자동완성 요청 스키마"""
model_config = ConfigDict(
json_schema_extra={
"example": {
'title': '<b>스테이</b>,<b>머뭄</b>',
'address': '전북특별자치도 군산시 신흥동 63-18',
'roadAddress': '전북특별자치도 군산시 절골길 18',
}
}
)
title: str = Field(..., description="네이버 검색 place API Title")
address: str = Field(..., description="네이버 검색 place API 지번주소")
roadAddress: Optional[str] = Field(None, description="네이버 검색 place API 도로명주소")
class AccommodationSearchItem(BaseModel):
"""숙박 검색 결과 아이템"""
title: str = Field(..., description="숙소명 (HTML 태그 포함 가능)")
address: str = Field(..., description="지번 주소")
roadAddress: str = Field(default="", description="도로명 주소")
class AccommodationSearchResponse(BaseModel):
"""숙박 자동완성 검색 응답"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"query": "스테이 머뭄",
"count": 2,
"items": [
{
"title": "<b>스테이</b>,<b>머뭄</b>",
"address": "전북특별자치도 군산시 신흥동 63-18",
"roadAddress": "전북특별자치도 군산시 절골길 18",
},
{
"title": "머뭄<b>스테이</b>",
"address": "전북특별자치도 군산시 비응도동 123",
"roadAddress": "전북특별자치도 군산시 비응로 456",
},
],
}
}
)
query: str = Field(..., description="검색어")
count: int = Field(..., description="검색 결과 수")
items: list[AccommodationSearchItem] = Field(
default_factory=list, description="검색 결과 목록"
)
class ProcessedInfo(BaseModel):
"""가공된 장소 정보 스키마"""
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
# class MarketingAnalysisDetail(BaseModel):
# detail_title : str = Field(..., description="디테일 카테고리 이름")
# detail_description : str = Field(..., description="해당 항목 설명")
# class MarketingAnalysisReport(BaseModel):
# """마케팅 분석 리포트 스키마"""
# summary : str = Field(..., description="비즈니스 한 줄 요약")
# details : list[MarketingAnalysisDetail] = Field(default_factory=list, description="개별 디테일")
# class MarketingAnalysis(BaseModel):
# """마케팅 분석 결과 스키마"""
# # report: MarketingAnalysisReport = Field(..., description="마케팅 분석 리포트")
# tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
# selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
class CrawlingResponse(BaseModel):
"""크롤링 응답 스키마"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"status": "completed",
"image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"],
"image_count": 2,
"processed_info": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "전북특별자치도 군산시 절골길 18"
},
"marketing_analysis": {
"brand_identity": {
"location_feature_analysis": "전북 군산시 절골길 일대는 도시의 편의성과 근교의 한적함을 동시에 누릴 수 있어 ‘조용한 재충전’ 수요에 적합합니다. 군산의 레트로 감성과 주변 관광 동선 결합이 쉬워 1~2박 체류형 여행지로 매력적입니다.",
"concept_scalability": "‘머뭄’이라는 네이밍을 ‘잠시 멈춰 머무는 시간’으로 확장해, 느린 체크인·명상/독서 큐레이션·로컬 티/다과 등 체류 경험형 서비스로 고도화가 가능합니다. 로컬 콘텐츠(군산 빵/커피, 근대문화 투어)와 결합해 패키지화하면 재방문 명분을 만들 수 있습니다."
},
"market_positioning": {
"category_definition": "군산 감성 ‘슬로우 스테이’ 프라이빗 숙소",
"core_value": "아무것도 하지 않아도 회복되는 ‘멈춤의 시간’"
},
"target_persona": [
{
"persona": "번아웃 회복형 직장인 커플: 주말에 조용히 쉬며 리셋을 원하는 2인 여행자",
"age": {
"min_age": 27,
"max_age": 39
},
"favor_target": [
"조용한 동네 분위기",
"미니멀/내추럴 인테리어",
"편안한 침구와 숙면 환경",
"셀프 체크인 선호",
"카페·맛집 연계 동선"
],
"decision_trigger": "‘조용히 쉬는 데 최적화’된 프라이빗함과 숙면 컨디션(침구/동선/소음 차단) 확신"
},
{
"persona": "감성 기록형 친구 여행: 사진과 무드를 위해 공간을 선택하는 2~3인 여성 그룹",
"age": {
"min_age": 23,
"max_age": 34
},
"favor_target": [
"자연광 좋은 공간",
"감성 소품/컬러 톤",
"포토존(거울/창가/테이블)",
"와인·디저트 페어링",
"야간 무드 조명"
],
"decision_trigger": "사진이 ‘그대로 작품’이 되는 포토 스팟과 야간 무드 연출 요소"
},
{
"persona": "로컬 탐험형 소도시 여행자: 군산의 레트로/로컬 콘텐츠를 깊게 즐기는 커플·솔로",
"age": {
"min_age": 28,
"max_age": 45
},
"favor_target": [
"근대문화/레트로 감성",
"로컬 맛집·빵집 투어",
"동선 효율(차로 이동 용이)",
"체크아웃 후 관광 연계",
"조용한 밤"
],
"decision_trigger": "‘군산 로컬 동선’과 결합하기 좋은 위치 + 숙소 자체의 휴식 완성도"
}
],
"selling_points": [
{
"english_category": "LOCATION",
"korean_category": "입지 환경",
"description": "군산 감성 동선",
"score": 88
},
{
"english_category": "HEALING",
"korean_category": "힐링 요소",
"description": "멈춤이 되는 쉼",
"score": 92
},
{
"english_category": "PRIVACY",
"korean_category": "프라이버시",
"description": "방해 없는 머뭄",
"score": 86
},
{
"english_category": "NIGHT MOOD",
"korean_category": "야간 감성",
"description": "밤이 예쁜 조명",
"score": 84
},
{
"english_category": "PHOTO SPOT",
"korean_category": "포토 스팟",
"description": "자연광 포토존",
"score": 83
},
{
"english_category": "SHORT GETAWAY",
"korean_category": "숏브레이크",
"description": "주말 리셋 스테이",
"score": 89
},
{
"english_category": "HOSPITALITY",
"korean_category": "서비스",
"description": "세심한 웰컴감",
"score": 80
}
],
"target_keywords": [
"군산숙소",
"군산감성숙소",
"전북숙소추천",
"군산여행",
"커플스테이",
"주말여행",
"감성스테이",
"조용한숙소",
"힐링스테이",
"스테이머뭄"
]
},
"m_id" : 1
}
}
)
status: str = Field(
default="completed",
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
)
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
image_count: int = Field(..., description="이미지 개수")
processed_info: Optional[ProcessedInfo] = Field(
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
)
marketing_analysis: Optional[MarketingPromptOutput] = Field(
None, description="마케팅 분석 결과 . 실패 시 null"
)
m_id : int = Field(..., description="마케팅 분석 결과 ID")
class ErrorResponse(BaseModel):
"""에러 응답 스키마"""
success: bool = Field(default=False, description="요청 성공 여부")
error_code: str = Field(..., description="에러 코드")
message: str = Field(..., description="에러 메시지")
detail: Optional[str] = Field(None, description="상세 에러 정보")
# =============================================================================
# Image Upload Schemas
# =============================================================================
class ImageUrlItem(BaseModel):
"""이미지 URL 아이템 스키마"""
url: str = Field(..., description="외부 이미지 URL")
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class ImageUploadResultItem(BaseModel):
"""업로드된 이미지 결과 아이템"""
id: int = Field(..., description="이미지 ID")
img_name: str = Field(..., description="이미지명")
img_url: str = Field(..., description="이미지 URL")
img_order: int = Field(..., description="이미지 순서")
source: Literal["url", "file", "blob"] = Field(
..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)"
)
class ImageUploadResponse(BaseModel):
"""이미지 업로드 응답 스키마"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"total_count": 3,
"url_count": 2,
"file_count": 1,
"saved_count": 3,
"images": [
{
"id": 1,
"img_name": "외관",
"img_url": "https://example.com/images/image_001.jpg",
"img_order": 0,
"source": "url",
},
{
"id": 2,
"img_name": "내부",
"img_url": "https://example.com/images/image_002.jpg",
"img_order": 1,
"source": "url",
},
{
"id": 3,
"img_name": "uploaded_image.jpg",
"img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
"img_order": 2,
"source": "file",
},
],
"image_urls": [
"https://example.com/images/image_001.jpg",
"https://example.com/images/image_002.jpg",
"/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
],
}
}
)
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)")
total_count: int = Field(..., description="총 업로드된 이미지 개수")
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")

View File

@ -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()

View File

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

View File

@ -0,0 +1,60 @@
"""
Home Worker 모듈
이미지 업로드 관련 백그라운드 작업을 처리합니다.
"""
from pathlib import Path
import aiofiles
from fastapi import UploadFile
from app.utils.upload_blob_as_request import AzureBlobUploader
async def save_upload_file(file: UploadFile, save_path: Path) -> None:
"""업로드 파일을 지정된 경로에 저장"""
save_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(save_path, "wb") as f:
content = await file.read()
await f.write(content)
async def upload_image_to_blob(
task_id: str,
user_uuid: str,
file: UploadFile,
filename: str,
save_dir: Path,
) -> tuple[bool, str, str]:
"""
이미지 파일을 media에 저장하고 Azure Blob Storage에 업로드합니다.
Args:
task_id: 작업 고유 식별자
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
file: 업로드할 파일 객체
filename: 저장될 파일명
save_dir: media 저장 디렉토리 경로
Returns:
tuple[bool, str, str]: (업로드 성공 여부, blob_url 또는 에러 메시지, media_path)
"""
save_path = save_dir / filename
media_path = str(save_path)
try:
# 1. media에 파일 저장
await save_upload_file(file, save_path)
# 2. Azure Blob Storage에 업로드
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
upload_success = await uploader.upload_image(file_path=str(save_path))
if upload_success:
return True, uploader.public_url, media_path
else:
return False, f"Failed to upload {filename} to Blob", media_path
except Exception as e:
return False, str(e), media_path

View File

@ -1,91 +0,0 @@
import asyncio
from sqlalchemy import select
from app.database.session import get_worker_session
from app.home.schemas.home import GenerateRequest
from app.lyric.models import Lyric
from app.utils.chatgpt_prompt import ChatgptService
async def _save_lyric(task_id: str, project_id: int, lyric_prompt: str) -> int:
"""Lyric 레코드를 DB에 저장 (status=processing, lyric_result=null)"""
async with get_worker_session() as session:
lyric = Lyric(
task_id=task_id,
project_id=project_id,
status="processing",
lyric_prompt=lyric_prompt,
lyric_result=None,
)
session.add(lyric)
await session.commit()
await session.refresh(lyric)
print(f"Lyric saved: id={lyric.id}, task_id={task_id}, status=processing")
return lyric.id
async def _update_lyric_status(lyric_id: int, status: str, lyric_result: str | None = None) -> None:
"""Lyric 레코드의 status와 lyric_result를 업데이트"""
async with get_worker_session() as session:
result = await session.execute(select(Lyric).where(Lyric.id == lyric_id))
lyric = result.scalar_one_or_none()
if lyric:
lyric.status = status
if lyric_result is not None:
lyric.lyric_result = lyric_result
await session.commit()
print(f"Lyric updated: id={lyric_id}, status={status}")
async def lyric_task(
task_id: str,
project_id: int,
customer_name: str,
region: str,
detail_region_info: str,
language: str = "Korean",
) -> None:
"""가사 생성 작업: ChatGPT로 가사 생성 및 Lyric 테이블 저장/업데이트"""
service = ChatgptService(
customer_name=customer_name,
region=region,
detail_region_info=detail_region_info,
language=language,
)
# Lyric 레코드 저장 (status=processing, lyric_result=null)
lyric_prompt = service.build_lyrics_prompt()
lyric_id = await _save_lyric(task_id, project_id, lyric_prompt)
# GPT 호출
result = await service.generate(prompt=lyric_prompt)
print(f"GPT Response:\n{result}")
# 결과에 ERROR가 포함되어 있으면 status를 failed로 업데이트
if "ERROR:" in result:
await _update_lyric_status(lyric_id, "failed", lyric_result=result)
else:
await _update_lyric_status(lyric_id, "completed", lyric_result=result)
async def _task_process_async(request_body: GenerateRequest, task_id: str, project_id: int) -> None:
"""백그라운드 작업 처리 (async 버전)"""
customer_name = request_body.customer_name
region = request_body.region
detail_region_info = request_body.detail_region_info or ""
language = request_body.language
print(f"customer_name: {customer_name}")
print(f"region: {region}")
print(f"detail_region_info: {detail_region_info}")
print(f"language: {language}")
# 가사 생성 작업
await lyric_task(task_id, project_id, customer_name, region, detail_region_info, language)
def task_process(request_body: GenerateRequest, task_id: str, project_id: int) -> None:
"""백그라운드 작업 처리 함수 (sync wrapper)"""
asyncio.run(_task_process_async(request_body, task_id, project_id))

View File

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

View File

@ -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(
project = Project( # customer_name=request_body.customer_name,
store_name=request_body.customer_name, # region=request_body.region,
region=request_body.region, # detail_region_info=request_body.detail_region_info or "",
task_id=task_id, # language=request_body.language,
detail_region_info=request_body.detail_region_info, # )
language=request_body.language,
)
session.add(project)
await session.commit()
await session.refresh(project) # commit 후 project.id 동기화
print(
f"[generate_lyric] Project saved - project_id: {project.id}, task_id: {task_id}"
)
# 3. Lyric 테이블에 데이터 저장 (status: processing) # 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" : """
812 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(
store_name=request_body.customer_name,
region=request_body.region,
task_id=task_id,
detail_region_info=request_body.detail_region_info,
language=request_body.language,
user_uuid=current_user.user_uuid,
marketing_intelligence = request_body.m_id
)
session.add(project)
await session.commit()
await session.refresh(project)
logger.info(f"[generate_lyric] 새 Project 생성 - project_id: {project.id}, task_id: {task_id}")
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()
return GenerateLyricResponse(
success=False,
task_id=task_id,
lyric=None,
language=request_body.language,
error_message=result,
)
# 6. 성공 시 Lyric 테이블 업데이트 (status: completed)
lyric.status = "completed"
lyric.lyric_result = result
await session.commit() await session.commit()
await session.refresh(lyric)
print(f"[generate_lyric] SUCCESS - task_id: {task_id}") step3_elapsed = (time.perf_counter() - step3_start) * 1000
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,
prompt=lyric_prompt,
lyric_input_data=lyric_input_data,
lyric_id=lyric.id,
)
step4_elapsed = (time.perf_counter() - step4_start) * 1000
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
# ========== 완료 ==========
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로 생성된 가사를 조회합니다."""

View File

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

View File

@ -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,

View File

@ -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:
{ {
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"customer_name": "스테이 머뭄", "customer_name": "스테이 머뭄",
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean" "language": "Korean",
"m_id" : 1
} }
""" """
model_config = { model_config = ConfigDict(
"json_schema_extra": { json_schema_extra={
"example": { "example": {
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"customer_name": "스테이 머뭄", "customer_name": "스테이 머뭄",
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean", "language": "Korean",
"m_id" : 1
} }
} }
} )
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에 에러 메시지가 저장됩니다.
"id": 1, - 에러 메시지 형식: "ChatGPT Error: {message}" 또는 "Error: {message}"
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"project_id": 1,
"status": "completed",
"lyric_prompt": "...",
"lyric_result": "생성된 가사...",
"created_at": "2024-01-01T12:00:00"
}
""" """
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"summary": "성공",
"value": {
"id": 1,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"project_id": 1,
"status": "completed",
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
"created_at": "2024-01-15T12:00:00",
}
},
{
"summary": "실패",
"value": {
"id": 1,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"project_id": 1,
"status": "failed",
"lyric_result": "ChatGPT Error: Response incomplete: max_output_tokens",
"created_at": "2024-01-15T12:00:00",
}
}
]
}
)
id: int = Field(..., description="가사 ID") 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="처리 상태")

View File

@ -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", ""),
) )

View File

@ -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="서비스 처리 중 오류가 발생했습니다.",
)

View File

@ -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
app/sns/__init__.py Normal file
View File

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

View File

View File

View File

@ -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}")

72
app/sns/api/sns_admin.py Normal file
View File

@ -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
app/sns/dependency.py Normal file
View File

183
app/sns/models.py Normal file
View File

@ -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")>"
)

View File

View File

@ -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

View File

0
app/sns/services/sns.py Normal file
View File

View File

View File

View File

View File

View File

0
app/sns/tests/test_db.py Normal file
View File

View File

View File

15
app/social/__init__.py Normal file
View File

@ -0,0 +1,15 @@
"""
Social Media Integration Module
소셜 미디어 플랫폼 연동 영상 업로드 기능을 제공합니다.
지원 플랫폼:
- YouTube (구현됨)
- Instagram (추후 구현)
- Facebook (추후 구현)
- TikTok (추후 구현)
"""
from app.social.constants import SocialPlatform, UploadStatus
__all__ = ["SocialPlatform", "UploadStatus"]

View File

@ -0,0 +1,3 @@
"""
Social API Module
"""

View File

@ -0,0 +1,3 @@
"""
Social API Routers
"""

View File

@ -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"]

View File

@ -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} 계정 연동이 해제되었습니다.",
)

View File

@ -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

View File

@ -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="업로드가 취소되었습니다.",
)

125
app/social/constants.py Normal file
View File

@ -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",
# ]

331
app/social/exceptions.py Normal file
View File

@ -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",
)

256
app/social/models.py Normal file
View File

@ -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")>"
)

View File

@ -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",
]

113
app/social/oauth/base.py Normal file
View File

@ -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

326
app/social/oauth/youtube.py Normal file
View File

@ -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()

324
app/social/schemas.py Normal file
View File

@ -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": "작업이 완료되었습니다.",
}
}
)

742
app/social/services.py Normal file
View File

@ -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()

View File

@ -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",
]

168
app/social/uploader/base.py Normal file
View File

@ -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 ""

View File

@ -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()

View File

@ -0,0 +1,9 @@
"""
Social Worker Module
소셜 미디어 백그라운드 태스크 모듈입니다.
"""
from app.social.worker.upload_task import process_social_upload
__all__ = ["process_social_upload"]

View File

@ -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}")

View File

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

View File

@ -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_save_song 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,430 +87,462 @@ 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_result = await session.execute( # Project 조회 (중복 시 최신 것 선택)
select(Project).where(Project.task_id == task_id) project_result = await session.execute(
) select(Project)
project = project_result.scalar_one_or_none() .where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
if not project: .limit(1)
print(f"[generate_song] Project NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
) )
print(f"[generate_song] Project found - project_id: {project.id}, task_id: {task_id}") project = project_result.scalar_one_or_none()
# 2. task_id로 Lyric 조회 if not project:
lyric_result = await session.execute( logger.warning(
select(Lyric).where(Lyric.task_id == task_id) f"[generate_song] Project NOT FOUND - task_id: {task_id}"
) )
lyric = lyric_result.scalar_one_or_none() raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
)
project_id = project.id
if not lyric: # Lyric 조회 (중복 시 최신 것 선택)
print(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}") lyric_result = await session.execute(
raise HTTPException( select(Lyric)
status_code=404, .where(Lyric.task_id == task_id)
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", .order_by(Lyric.created_at.desc())
.limit(1)
)
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}"
) )
print(f"[generate_song] Lyric found - lyric_id: {lyric.id}, task_id: {task_id}")
# 3. Song 테이블에 초기 데이터 저장 if not lyric:
song_prompt = ( logger.warning(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}")
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}" raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
)
lyric_id = lyric.id
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 = (
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(
project_id=project_id,
lyric_id=lyric_id,
task_id=task_id,
suno_task_id=None,
status="processing",
song_prompt=song_prompt,
language=request_body.language,
)
session.add(song)
await session.commit()
song_id = song.id
stage1_time = time.perf_counter()
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(
song = Song( success=False,
project_id=project.id,
lyric_id=lyric.id,
task_id=task_id, task_id=task_id,
suno_task_id=None, song_id=None,
status="processing", message="노래 생성 요청에 실패했습니다.",
song_prompt=song_prompt, error_message=str(e),
language=request_body.language,
) )
session.add(song)
await session.flush() # ID 생성을 위해 flush
print(f"[generate_song] Song saved (processing) - task_id: {task_id}")
# 4. Suno API 호출 # ==========================================================================
print(f"[generate_song] Suno API generation started - task_id: {task_id}") # 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 파일을 다운로드하고 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 다운로드 DB 업데이트 진행 - Song 테이블 상태: processing uploading completed
""", """,
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 파일을 다운로드하고
Song 테이블의 status를 completed로, song_result_url을 업데이트합니다. Azure Blob Storage에 업로드한 Song 테이블의 status를 completed로,
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 가져오기 return parsed_response
first_clip = parsed_response.clips[0]
audio_url = first_clip.audio_url
if audio_url: # SUCCESS 상태인 경우 백그라운드에서 MP3 다운로드 및 Blob 업로드 진행
# suno_task_id로 Song 조회하여 task_id 가져오기 (여러 개 있을 경우 가장 최근 것 선택) if parsed_response.status == "SUCCESS" and result:
song_result = await session.execute( # result에서 직접 clips 데이터 추출
select(Song) data = result.get("data", {})
.where(Song.suno_task_id == suno_task_id) response_data = data.get("response") or {}
.order_by(Song.created_at.desc()) clips_data = response_data.get("sunoData") or []
.limit(1)
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}"
) )
song = song_result.scalar_one_or_none()
if song: if audio_url:
# task_id로 Project 조회하여 store_name 가져오기 # song_id로 Song 조회
project_result = await session.execute( song_result = await session.execute(
select(Project).where(Project.id == song.project_id) select(Song)
.where(Song.suno_task_id == song_id)
.order_by(Song.created_at.desc())
.limit(1)
) )
project = project_result.scalar_one_or_none() song = song_result.scalar_one_or_none()
store_name = project.store_name if project else "song" # processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
if song and song.status == "processing":
# 상태를 uploading으로 변경 (중복 호출 방지)
song.status = "uploading"
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}"
)
# 백그라운드 태스크로 MP3 다운로드 및 DB 업데이트 # 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행
print(f"[get_song_status] Background task args - task_id: {song.task_id}, audio_url: {audio_url}, store_name: {store_name}") background_tasks.add_task(
background_tasks.add_task( download_and_upload_song_by_suno_task_id,
download_and_save_song, suno_task_id=song_id,
task_id=song.task_id, audio_url=audio_url,
audio_url=audio_url, user_uuid=current_user.user_uuid,
store_name=store_name, duration=clip_duration,
)
logger.info(
f"[get_song_status] Background task scheduled - song_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",
)
print(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}") 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="노래 다운로드 상태 조회",
description="""
task_id를 기반으로 Song 테이블의 상태를 polling하고,
completed인 경우 Project 정보와 노래 URL을 반환합니다.
## 경로 파라미터
- **task_id**: 프로젝트 task_id (필수)
## 반환 정보
- **success**: 조회 성공 여부
- **status**: 처리 상태 (processing, completed, failed)
- **message**: 응답 메시지
- **store_name**: 업체명
- **region**: 지역명
- **detail_region_info**: 상세 지역 정보
- **task_id**: 작업 고유 식별자
- **language**: 언어
- **song_result_url**: 노래 결과 URL (completed )
- **created_at**: 생성 일시
## 사용 예시
```
GET /song/download/019123ab-cdef-7890-abcd-ef1234567890
```
## 참고
- processing 상태인 경우 song_result_url은 null입니다.
- completed 상태인 경우 Project 정보와 함께 song_result_url을 반환합니다.
""",
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)}",
)

View File

@ -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": "생성일시",
}

View File

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

View File

@ -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(
@ -100,6 +108,11 @@ class Song(Base):
comment="노래 결과 URL", comment="노래 결과 URL",
) )
duration: Mapped[Optional[float]] = mapped_column(
nullable=True,
comment="노래 재생 시간 (초)",
)
language: Mapped[str] = mapped_column( language: Mapped[str] = mapped_column(
String(50), String(50),
nullable=False, nullable=False,
@ -107,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,
@ -145,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")>"
)

View File

@ -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", ""),
)

View File

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

Some files were not shown because too many files have changed in this diff Show More