diff --git a/.DS_Store b/.DS_Store index 8e90c8c..f07c7a1 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.claude/agents/design.md b/.claude/agents/design.md new file mode 100644 index 0000000..1ec07f2 --- /dev/null +++ b/.claude/agents/design.md @@ -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 원칙 준수 여부 확인 + +## 출력 +설계 문서를 화면에 출력합니다. diff --git a/.claude/agents/develop.md b/.claude/agents/develop.md new file mode 100644 index 0000000..d0eaaf5 --- /dev/null +++ b/.claude/agents/develop.md @@ -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가 누락되지 않았는가? +- 순환 참조가 발생하지 않는가? diff --git a/.claude/agents/review.md b/.claude/agents/review.md new file mode 100644 index 0000000..d974959 --- /dev/null +++ b/.claude/agents/review.md @@ -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: 코드 스타일, 베스트 프랙티스 권장 + +## 출력 +코드 리뷰 리포트를 화면에 출력합니다. diff --git a/.claude/commands/design.md b/.claude/commands/design.md new file mode 100644 index 0000000..6a92371 --- /dev/null +++ b/.claude/commands/design.md @@ -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` 명령으로 개발 에이전트를 호출하여 구현을 진행합니다. diff --git a/.claude/commands/develop.md b/.claude/commands/develop.md new file mode 100644 index 0000000..929107c --- /dev/null +++ b/.claude/commands/develop.md @@ -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` 명령으로 코드리뷰 에이전트를 호출하여 최종 검수를 진행합니다. diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 0000000..3d12edc --- /dev/null +++ b/.claude/commands/review.md @@ -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`)를 통해 수정합니다 diff --git a/.gitignore b/.gitignore index 2de45be..30273f5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,8 @@ __pycache__/ .env # Claude AI related files -.claude/ .claudeignore +# .claude/ 폴더는 커밋 대상 (에이전트 설정 포함) # VSCode settings .vscode/ @@ -29,4 +29,10 @@ build/ media/ -*.ipynb_checkpoint* \ No newline at end of file +*.ipynb_checkpoint* +# Static files +static/ + +# Log files +*.log +logs/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9d5a952 --- /dev/null +++ b/CLAUDE.md @@ -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 . +``` diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/api/__init__.py b/app/auth/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/api/routers/__init__.py b/app/auth/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/api/routers/v1/__init__.py b/app/auth/api/routers/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/api/routers/v1/auth.py b/app/auth/api/routers/v1/auth.py new file mode 100644 index 0000000..21485b5 --- /dev/null +++ b/app/auth/api/routers/v1/auth.py @@ -0,0 +1,125 @@ +""" +카카오 로그인 API 라우터 +""" + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import RedirectResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_user_optional +from app.auth.models import User +from app.auth.schemas import AuthStatusResponse, TokenResponse, UserResponse +from app.auth.services.jwt import create_access_token +from app.auth.services.kakao import kakao_client +from app.database.session import get_session + +router = APIRouter(tags=["Auth"]) + + +@router.get("/auth/kakao/login") +async def kakao_login(): + """ + 카카오 로그인 페이지로 리다이렉트 + + 프론트엔드에서 이 URL을 호출하면 카카오 로그인 페이지로 이동합니다. + """ + auth_url = kakao_client.get_authorization_url() + return RedirectResponse(url=auth_url) + + +@router.get("/kakao/callback", response_model=TokenResponse) +async def kakao_callback( + code: str, + session: AsyncSession = Depends(get_session), +): + """ + 카카오 로그인 콜백 + + 카카오 로그인 성공 후 인가 코드를 받아 JWT 토큰을 발급합니다. + """ + # 1. 인가 코드로 액세스 토큰 획득 + token_data = await kakao_client.get_access_token(code) + + if "error" in token_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"카카오 토큰 발급 실패: {token_data.get('error_description', token_data.get('error'))}", + ) + + access_token = token_data.get("access_token") + if not access_token: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="액세스 토큰을 받지 못했습니다", + ) + + # 2. 액세스 토큰으로 사용자 정보 조회 + user_info = await kakao_client.get_user_info(access_token) + + if "id" not in user_info: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="사용자 정보를 가져오지 못했습니다", + ) + + kakao_id = str(user_info["id"]) + kakao_account = user_info.get("kakao_account", {}) + profile = kakao_account.get("profile", {}) + + nickname = profile.get("nickname") + email = kakao_account.get("email") + profile_image = profile.get("profile_image_url") + + # 3. 기존 회원 확인 또는 신규 가입 + result = await session.execute(select(User).where(User.kakao_id == kakao_id)) + user = result.scalar_one_or_none() + + if user is None: + # 신규 가입 + user = User( + kakao_id=kakao_id, + nickname=nickname, + email=email, + profile_image=profile_image, + ) + session.add(user) + await session.commit() + await session.refresh(user) + else: + # 기존 회원 - 마지막 로그인 시간 및 정보 업데이트 + user.nickname = nickname + user.email = email + user.profile_image = profile_image + user.last_login_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(user) + + # 4. JWT 토큰 발급 + jwt_token = create_access_token({"sub": str(user.id)}) + + return TokenResponse( + access_token=jwt_token, + user=UserResponse.model_validate(user), + ) + + +@router.get("/auth/me", response_model=AuthStatusResponse) +async def get_auth_status( + current_user: User | None = Depends(get_current_user_optional), +): + """ + 현재 인증 상태 확인 + + 프론트엔드에서 로그인 상태를 확인할 때 사용합니다. + 토큰이 유효하면 사용자 정보를, 아니면 is_authenticated=False를 반환합니다. + """ + if current_user is None: + return AuthStatusResponse(is_authenticated=False) + + return AuthStatusResponse( + is_authenticated=True, + user=UserResponse.model_validate(current_user), + ) diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py new file mode 100644 index 0000000..85507b2 --- /dev/null +++ b/app/auth/dependencies.py @@ -0,0 +1,71 @@ +""" +Auth 모듈 의존성 주입 +""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.models import User +from app.auth.services.jwt import decode_access_token +from app.database.session import get_session + +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + session: AsyncSession = Depends(get_session), +) -> User: + """현재 로그인한 사용자 반환 (필수 인증)""" + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="인증이 필요합니다", + ) + + payload = decode_access_token(credentials.credentials) + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="유효하지 않은 토큰입니다", + ) + + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="유효하지 않은 토큰입니다", + ) + + result = await session.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="사용자를 찾을 수 없습니다", + ) + + return user + + +async def get_current_user_optional( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + session: AsyncSession = Depends(get_session), +) -> User | None: + """현재 로그인한 사용자 반환 (선택적 인증)""" + if credentials is None: + return None + + payload = decode_access_token(credentials.credentials) + if payload is None: + return None + + user_id = payload.get("sub") + if user_id is None: + return None + + result = await session.execute(select(User).where(User.id == int(user_id))) + return result.scalar_one_or_none() diff --git a/app/auth/models.py b/app/auth/models.py new file mode 100644 index 0000000..25a7150 --- /dev/null +++ b/app/auth/models.py @@ -0,0 +1,88 @@ +""" +Auth 모듈 SQLAlchemy 모델 정의 + +카카오 로그인 사용자 정보를 저장합니다. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.session import Base + + +class User(Base): + """ + 사용자 테이블 (카카오 로그인) + + Attributes: + id: 고유 식별자 (자동 증가) + kakao_id: 카카오 고유 ID + nickname: 카카오 닉네임 + email: 카카오 이메일 (선택) + profile_image: 프로필 이미지 URL + created_at: 가입 일시 + last_login_at: 마지막 로그인 일시 + """ + + __tablename__ = "user" + __table_args__ = ( + Index("idx_user_kakao_id", "kakao_id"), + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + kakao_id: Mapped[str] = mapped_column( + String(50), + nullable=False, + unique=True, + comment="카카오 고유 ID", + ) + + nickname: Mapped[str | None] = mapped_column( + String(100), + nullable=True, + comment="카카오 닉네임", + ) + + email: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + comment="카카오 이메일", + ) + + profile_image: Mapped[str | None] = mapped_column( + String(500), + nullable=True, + comment="프로필 이미지 URL", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="가입 일시", + ) + + last_login_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), + comment="마지막 로그인 일시", + ) + + def __repr__(self) -> str: + return f"" diff --git a/app/auth/schemas.py b/app/auth/schemas.py new file mode 100644 index 0000000..2b06b0e --- /dev/null +++ b/app/auth/schemas.py @@ -0,0 +1,36 @@ +""" +Auth 모듈 Pydantic 스키마 +""" + +from datetime import datetime + +from pydantic import BaseModel + + +class UserResponse(BaseModel): + """사용자 정보 응답""" + + id: int + kakao_id: str + nickname: str | None + email: str | None + profile_image: str | None + created_at: datetime + last_login_at: datetime + + model_config = {"from_attributes": True} + + +class TokenResponse(BaseModel): + """토큰 응답""" + + access_token: str + token_type: str = "bearer" + user: UserResponse + + +class AuthStatusResponse(BaseModel): + """인증 상태 응답 (프론트엔드용)""" + + is_authenticated: bool + user: UserResponse | None = None diff --git a/app/auth/services/__init__.py b/app/auth/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/services/jwt.py b/app/auth/services/jwt.py new file mode 100644 index 0000000..88bcc6a --- /dev/null +++ b/app/auth/services/jwt.py @@ -0,0 +1,34 @@ +""" +JWT 토큰 유틸리티 +""" + +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt + +from config import security_settings + + +def create_access_token(data: dict) -> str: + """JWT 액세스 토큰 생성""" + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(minutes=security_settings.JWT_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + return jwt.encode( + to_encode, + security_settings.JWT_SECRET, + algorithm=security_settings.JWT_ALGORITHM, + ) + + +def decode_access_token(token: str) -> dict | None: + """JWT 액세스 토큰 디코딩""" + try: + payload = jwt.decode( + token, + security_settings.JWT_SECRET, + algorithms=[security_settings.JWT_ALGORITHM], + ) + return payload + except JWTError: + return None diff --git a/app/auth/services/kakao.py b/app/auth/services/kakao.py new file mode 100644 index 0000000..68b5aee --- /dev/null +++ b/app/auth/services/kakao.py @@ -0,0 +1,55 @@ +""" +카카오 OAuth API 클라이언트 +""" + +import aiohttp + +from config import kakao_settings + + +class KakaoOAuthClient: + """카카오 OAuth API 클라이언트""" + + AUTH_URL = "https://kauth.kakao.com/oauth/authorize" + TOKEN_URL = "https://kauth.kakao.com/oauth/token" + USER_INFO_URL = "https://kapi.kakao.com/v2/user/me" + + def __init__(self): + self.client_id = kakao_settings.KAKAO_CLIENT_ID + self.client_secret = kakao_settings.KAKAO_CLIENT_SECRET + self.redirect_uri = kakao_settings.KAKAO_REDIRECT_URI + + def get_authorization_url(self) -> str: + """카카오 로그인 페이지 URL 반환""" + return ( + f"{self.AUTH_URL}" + f"?client_id={self.client_id}" + f"&redirect_uri={self.redirect_uri}" + f"&response_type=code" + ) + + async def get_access_token(self, code: str) -> dict: + """인가 코드로 액세스 토큰 획득""" + async with aiohttp.ClientSession() as session: + data = { + "grant_type": "authorization_code", + "client_id": self.client_id, + "client_secret": self.client_secret, + "redirect_uri": self.redirect_uri, + "code": code, + } + print(f"[kakao] Token request - client_id: {self.client_id}, redirect_uri: {self.redirect_uri}") + async with session.post(self.TOKEN_URL, data=data) as response: + result = await response.json() + print(f"[kakao] Token response: {result}") + return result + + async def get_user_info(self, access_token: str) -> dict: + """액세스 토큰으로 사용자 정보 조회""" + async with aiohttp.ClientSession() as session: + headers = {"Authorization": f"Bearer {access_token}"} + async with session.get(self.USER_INFO_URL, headers=headers) as response: + return await response.json() + + +kakao_client = KakaoOAuthClient() diff --git a/app/core/common.py b/app/core/common.py index 1d6d621..520fab4 100644 --- a/app/core/common.py +++ b/app/core/common.py @@ -4,12 +4,16 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from app.utils.logger import get_logger + +logger = get_logger("core") + @asynccontextmanager async def lifespan(app: FastAPI): """FastAPI 애플리케이션 생명주기 관리""" # Startup - 애플리케이션 시작 시 - print("Starting up...") + logger.info("Starting up...") try: from config import prj_settings @@ -19,20 +23,20 @@ async def lifespan(app: FastAPI): from app.database.session import create_db_tables await create_db_tables() - print("Database tables created (DEBUG mode)") + logger.info("Database tables created (DEBUG mode)") except asyncio.TimeoutError: - print("Database initialization timed out") + logger.error("Database initialization timed out") # 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass raise except Exception as e: - print(f"Database initialization failed: {e}") + logger.error(f"Database initialization failed: {e}") # 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass raise yield # 애플리케이션 실행 중 # Shutdown - 애플리케이션 종료 시 - print("Shutting down...") + logger.info("Shutting down...") # 공유 HTTP 클라이언트 종료 from app.utils.creatomate import close_shared_client diff --git a/app/core/exceptions.py b/app/core/exceptions.py index e0399c1..63cba13 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -1,4 +1,3 @@ -import logging import traceback from functools import wraps from typing import Any, Callable, TypeVar @@ -7,8 +6,10 @@ from fastapi import FastAPI, HTTPException, Request, Response, status from fastapi.responses import JSONResponse from sqlalchemy.exc import SQLAlchemyError +from app.utils.logger import get_logger + # 로거 설정 -logger = logging.getLogger(__name__) +logger = get_logger("core") T = TypeVar("T") @@ -159,16 +160,14 @@ def handle_db_exceptions( raise except SQLAlchemyError as e: logger.error(f"[DB Error] {func.__name__}: {e}") - logger.error(traceback.format_exc()) - print(f"[DB Error] {func.__name__}: {e}") + logger.debug(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=error_message, ) except Exception as e: logger.error(f"[Unexpected Error] {func.__name__}: {e}") - logger.error(traceback.format_exc()) - print(f"[Unexpected Error] {func.__name__}: {e}") + logger.debug(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="서비스 처리 중 예기치 않은 오류가 발생했습니다.", @@ -205,8 +204,7 @@ def handle_external_service_exceptions( except Exception as e: msg = error_message or f"{service_name} 서비스 호출 중 오류가 발생했습니다." logger.error(f"[{service_name} Error] {func.__name__}: {e}") - logger.error(traceback.format_exc()) - print(f"[{service_name} Error] {func.__name__}: {e}") + logger.debug(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=msg, @@ -240,16 +238,14 @@ def handle_api_exceptions( raise except SQLAlchemyError as e: logger.error(f"[API DB Error] {func.__name__}: {e}") - logger.error(traceback.format_exc()) - print(f"[API DB Error] {func.__name__}: {e}") + logger.debug(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="데이터베이스 연결에 문제가 발생했습니다.", ) except Exception as e: logger.error(f"[API Error] {func.__name__}: {e}") - logger.error(traceback.format_exc()) - print(f"[API Error] {func.__name__}: {e}") + logger.debug(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error_message, @@ -263,17 +259,8 @@ def handle_api_exceptions( def _get_handler(status: int, detail: str): # Define def handler(request: Request, exception: Exception) -> Response: - # DEBUG PRINT STATEMENT 👇 - from rich import print, panel - print( - panel.Panel( - exception.__class__.__name__, - title="Handled Exception", - border_style="red", - ), - ) - # DEBUG PRINT STATEMENT 👆 - + logger.debug(f"Handled Exception: {exception.__class__.__name__}") + # Raise HTTPException with given status and detail # can return JSONResponse as well raise HTTPException( diff --git a/app/database/session-prod.py b/app/database/session-prod.py index cae7289..02f01c9 100644 --- a/app/database/session-prod.py +++ b/app/database/session-prod.py @@ -9,8 +9,11 @@ from sqlalchemy.ext.asyncio import ( from sqlalchemy.orm import DeclarativeBase from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스 +from app.utils.logger import get_logger from config import db_settings +logger = get_logger("database") + # Base 클래스 정의 class Base(DeclarativeBase): @@ -61,7 +64,7 @@ async def create_db_tables() -> None: async with engine.begin() as conn: # from app.database.models import Shipment, Seller # noqa: F401 await conn.run_sync(Base.metadata.create_all) - print("MySQL tables created successfully") + logger.info("MySQL tables created successfully") # 세션 제너레이터 (FastAPI Depends에 사용) @@ -80,13 +83,13 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: # FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback) except Exception as e: await session.rollback() # 명시적 롤백 (선택적) - print(f"Session rollback due to: {e}") # 로깅 + logger.error(f"Session rollback due to: {e}") raise finally: # 명시적 세션 종료 (Connection Pool에 반환) # context manager가 자동 처리하지만, 명시적으로 유지 await session.close() - print("session closed successfully") + logger.debug("session closed successfully") # 또는 session.aclose() - Python 3.10+ @@ -94,4 +97,4 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: async def dispose_engine() -> None: """애플리케이션 종료 시 모든 연결 해제""" await engine.dispose() - print("Database engine disposed") + logger.info("Database engine disposed") diff --git a/app/database/session.py b/app/database/session.py index dd8c6db..8e35772 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -4,8 +4,11 @@ from typing import AsyncGenerator from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase +from app.utils.logger import get_logger from config import db_settings +logger = get_logger("database") + class Base(DeclarativeBase): pass @@ -69,12 +72,14 @@ async def create_db_tables(): import asyncio # 모델 import (테이블 메타데이터 등록용) - from app.home.models import Image, Project # noqa: F401 + # 주의: User를 먼저 import해야 UserProject가 User를 참조할 수 있음 + from app.user.models import User # noqa: F401 + from app.home.models import Image, Project, UserProject # noqa: F401 from app.lyric.models import Lyric # noqa: F401 from app.song.models import Song # noqa: F401 from app.video.models import Video # noqa: F401 - print("Creating database tables...") + logger.info("Creating database tables...") async with asyncio.timeout(10): async with engine.begin() as connection: @@ -87,7 +92,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: pool = engine.pool # 커넥션 풀 상태 로깅 (디버깅용) - print( + logger.debug( f"[get_session] ACQUIRE - pool_size: {pool.size()}, " f"in: {pool.checkedin()}, out: {pool.checkedout()}, " f"overflow: {pool.overflow()}" @@ -95,7 +100,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: async with AsyncSessionLocal() as session: acquire_time = time.perf_counter() - print( + logger.debug( f"[get_session] Session acquired in " f"{(acquire_time - start_time)*1000:.1f}ms" ) @@ -103,14 +108,14 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: yield session except Exception as e: await session.rollback() - print( + logger.error( f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, " f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" ) raise e finally: total_time = time.perf_counter() - start_time - print( + logger.debug( f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, " f"pool_out: {pool.checkedout()}" ) @@ -121,7 +126,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]: start_time = time.perf_counter() pool = background_engine.pool - print( + logger.debug( f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, " f"in: {pool.checkedin()}, out: {pool.checkedout()}, " f"overflow: {pool.overflow()}" @@ -129,7 +134,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]: async with BackgroundSessionLocal() as session: acquire_time = time.perf_counter() - print( + logger.debug( f"[get_background_session] Session acquired in " f"{(acquire_time - start_time)*1000:.1f}ms" ) @@ -137,7 +142,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]: yield session except Exception as e: await session.rollback() - print( + logger.error( f"[get_background_session] ROLLBACK - " f"error: {type(e).__name__}: {e}, " f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" @@ -145,7 +150,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]: raise e finally: total_time = time.perf_counter() - start_time - print( + logger.debug( f"[get_background_session] RELEASE - " f"duration: {total_time*1000:.1f}ms, " f"pool_out: {pool.checkedout()}" @@ -154,8 +159,8 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]: # 앱 종료 시 엔진 리소스 정리 함수 async def dispose_engine() -> None: - print("[dispose_engine] Disposing database engines...") + logger.info("[dispose_engine] Disposing database engines...") await engine.dispose() - print("[dispose_engine] Main engine disposed") + logger.info("[dispose_engine] Main engine disposed") await background_engine.dispose() - print("[dispose_engine] Background engine disposed - ALL DONE") + logger.info("[dispose_engine] Background engine disposed - ALL DONE") diff --git a/app/home/api/routers/v1/__init__.py b/app/home/api/routers/v1/__init__.py index 75a7215..6e0ed83 100644 --- a/app/home/api/routers/v1/__init__.py +++ b/app/home/api/routers/v1/__init__.py @@ -1,15 +1,3 @@ -"""API 1 Version Router Module.""" - -# from fastapi import APIRouter, Depends - -# API 버전 1 라우터를 정의합니다. -# router = APIRouter( -# prefix="/api/v1", -# dependencies=[Depends(check_use_api), Depends(set_current_connect)], -# ) -# router = APIRouter( -# prefix="/api/v1", -# dependencies=[Depends(check_use_api), Depends(set_current_connect)], -# ) -# router.include_router(auth.router, tags=[Tags.AUTH]) -# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD]) +""" +Home API v1 라우터 모듈 +""" diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 6920fc6..2635412 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -1,6 +1,5 @@ import json -import logging -import traceback +import time from datetime import date from pathlib import Path from typing import Optional @@ -26,13 +25,13 @@ from app.home.schemas.home_schema import ( from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.chatgpt_prompt import ChatgptService from app.utils.common import generate_task_id +from app.utils.logger import get_logger from app.utils.nvMapScraper import NvMapScraper, GraphQLException from app.utils.prompts.prompts import marketing_prompt +from config import MEDIA_ROOT # 로거 설정 -logger = logging.getLogger(__name__) - -MEDIA_ROOT = Path("media") +logger = get_logger("home") # 전국 시 이름 목록 (roadAddress에서 region 추출용) # fmt: off @@ -63,7 +62,7 @@ KOREAN_CITIES = [ ] # fmt: on -router = APIRouter() +router = APIRouter(tags=["Home"]) def _extract_region_from_address(road_address: str | None) -> str: @@ -107,16 +106,13 @@ def _extract_region_from_address(road_address: str | None) -> str: ) async def crawling(request_body: CrawlingRequest): """네이버 지도 장소 크롤링""" - import time - request_start = time.perf_counter() - logger.info(f"[crawling] START - url: {request_body.url[:80]}...") - print(f"[crawling] ========== START ==========") - print(f"[crawling] URL: {request_body.url[:80]}...") + logger.info("[crawling] ========== START ==========") + logger.info(f"[crawling] URL: {request_body.url[:80]}...") # ========== Step 1: 네이버 지도 크롤링 ========== step1_start = time.perf_counter() - print(f"[crawling] Step 1: 네이버 지도 크롤링 시작...") + logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...") try: scraper = NvMapScraper(request_body.url) @@ -124,7 +120,6 @@ async def crawling(request_body: CrawlingRequest): except GraphQLException as e: step1_elapsed = (time.perf_counter() - step1_start) * 1000 logger.error(f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)") - print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)") raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"네이버 지도 크롤링에 실패했습니다: {e}", @@ -132,8 +127,7 @@ async def crawling(request_body: CrawlingRequest): except Exception as e: step1_elapsed = (time.perf_counter() - step1_start) * 1000 logger.error(f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)") - print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)") - traceback.print_exc() + logger.exception("[crawling] Step 1 상세 오류:") raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="네이버 지도 크롤링 중 오류가 발생했습니다.", @@ -142,11 +136,10 @@ async def crawling(request_body: CrawlingRequest): step1_elapsed = (time.perf_counter() - step1_start) * 1000 image_count = len(scraper.image_link_list) if scraper.image_link_list else 0 logger.info(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)") - print(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)") # ========== Step 2: 정보 가공 ========== step2_start = time.perf_counter() - print(f"[crawling] Step 2: 정보 가공 시작...") + logger.info("[crawling] Step 2: 정보 가공 시작...") processed_info = None marketing_analysis = None @@ -164,18 +157,17 @@ async def crawling(request_body: CrawlingRequest): step2_elapsed = (time.perf_counter() - step2_start) * 1000 logger.info(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)") - print(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)") # ========== Step 3: ChatGPT 마케팅 분석 ========== step3_start = time.perf_counter() - print(f"[crawling] Step 3: ChatGPT 마케팅 분석 시작...") + logger.info("[crawling] Step 3: ChatGPT 마케팅 분석 시작...") try: # Step 3-1: ChatGPT 서비스 초기화 step3_1_start = time.perf_counter() chatgpt_service = ChatgptService() step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000 - print(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)") + logger.debug(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)") # Step 3-2: 프롬프트 생성 # step3_2_start = time.perf_counter() @@ -187,19 +179,18 @@ async def crawling(request_body: CrawlingRequest): # prompt = chatgpt_service.build_market_analysis_prompt() # prompt1 = marketing_prompt.build_prompt(input_marketing_data) # step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000 - # print(f"[crawling] Step 3-2: 프롬프트 생성 완료 - ({step3_2_elapsed:.1f}ms)") # Step 3-3: GPT API 호출 step3_3_start = time.perf_counter() structured_report = await chatgpt_service.generate_structured_output(marketing_prompt, input_marketing_data) step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000 logger.info(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)") - print(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)") + logger.debug(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)") # Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달) step3_4_start = time.perf_counter() - print(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}") + logger.debug(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}") # 요약 Deprecated / 20250115 / Selling points를 첫 prompt에서 추출 중 # parsed = await chatgpt_service.parse_marketing_analysis( @@ -218,36 +209,32 @@ async def crawling(request_body: CrawlingRequest): # print(sp['keywords']) # print(sp['description']) step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000 - print(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)") + logger.debug(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)") step3_elapsed = (time.perf_counter() - step3_start) * 1000 logger.info(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)") - print(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)") except Exception as e: step3_elapsed = (time.perf_counter() - step3_start) * 1000 logger.error(f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)") - print(f"[crawling] Step 3 FAILED - {e} ({step3_elapsed:.1f}ms)") - traceback.print_exc() + logger.exception("[crawling] Step 3 상세 오류:") # GPT 실패 시에도 크롤링 결과는 반환 marketing_analysis = None else: step2_elapsed = (time.perf_counter() - step2_start) * 1000 - logger.warning(f"[crawling] Step 2 - base_info 없음 ({step2_elapsed:.1f}ms)") - print(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)") + logger.warning(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)") # ========== 완료 ========== total_elapsed = (time.perf_counter() - request_start) * 1000 - logger.info(f"[crawling] COMPLETE - 총 소요시간: {total_elapsed:.1f}ms") - print(f"[crawling] ========== COMPLETE ==========") - print(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms") - print(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms") + logger.info("[crawling] ========== COMPLETE ==========") + logger.info(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms") + logger.info(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms") if scraper.base_info: - print(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms") + logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms") if 'step3_elapsed' in locals(): - print(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms") + logger.info(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms") if 'step3_3_elapsed' in locals(): - print(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms") + logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms") return { "image_list": scraper.image_link_list, @@ -629,12 +616,11 @@ async def upload_images_blob( - Stage 2: Azure Blob 업로드 (세션 없음) - Stage 3: DB 저장 (새 세션으로 빠르게 처리) """ - import time request_start = time.perf_counter() # task_id 생성 task_id = await generate_task_id() - print(f"[upload_images_blob] START - task_id: {task_id}") + logger.info(f"[upload_images_blob] START - task_id: {task_id}") # ========== Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) ========== has_images_json = images_json is not None and images_json.strip() != "" @@ -688,9 +674,9 @@ async def upload_images_blob( ) stage1_time = time.perf_counter() - print(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, " - f"files: {len(valid_files_data)}, " - f"elapsed: {(stage1_time - request_start)*1000:.1f}ms") + logger.info(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, " + f"files: {len(valid_files_data)}, " + f"elapsed: {(stage1_time - request_start)*1000:.1f}ms") # ========== Stage 2: Azure Blob 업로드 (세션 없음) ========== # 업로드 결과를 저장할 리스트 (나중에 DB에 저장) @@ -709,8 +695,8 @@ async def upload_images_blob( ) filename = f"{name_without_ext}_{img_order:03d}{ext}" - print(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: " - f"{filename} ({len(file_content)} bytes)") + logger.debug(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: " + f"{filename} ({len(file_content)} bytes)") # Azure Blob Storage에 직접 업로드 upload_success = await uploader.upload_image_bytes(file_content, filename) @@ -719,18 +705,18 @@ async def upload_images_blob( blob_url = uploader.public_url blob_upload_results.append((original_name, blob_url)) img_order += 1 - print(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS") + logger.debug(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS") else: skipped_files.append(filename) - print(f"[upload_images_blob] File {idx+1}/{total_files} FAILED") + logger.warning(f"[upload_images_blob] File {idx+1}/{total_files} FAILED") stage2_time = time.perf_counter() - print(f"[upload_images_blob] Stage 2 done - blob uploads: " - f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, " - f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms") + logger.info(f"[upload_images_blob] Stage 2 done - blob uploads: " + f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, " + f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms") # ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ========== - print("[upload_images_blob] Stage 3 starting - DB save...") + logger.info("[upload_images_blob] Stage 3 starting - DB save...") result_images: list[ImageUploadResultItem] = [] img_order = 0 @@ -786,21 +772,21 @@ async def upload_images_blob( await session.commit() stage3_time = time.perf_counter() - print(f"[upload_images_blob] Stage 3 done - " - f"saved: {len(result_images)}, " - f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms") + logger.info(f"[upload_images_blob] Stage 3 done - " + f"saved: {len(result_images)}, " + f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms") except SQLAlchemyError as e: logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}") - traceback.print_exc() + logger.exception("[upload_images_blob] DB 상세 오류:") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.", ) except Exception as e: logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - " - f"task_id: {task_id}, error: {type(e).__name__}: {e}") - traceback.print_exc() + f"task_id: {task_id}, error: {type(e).__name__}: {e}") + logger.exception("[upload_images_blob] Stage 3 상세 오류:") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="이미지 업로드 중 오류가 발생했습니다.", @@ -810,8 +796,8 @@ async def upload_images_blob( image_urls = [img.img_url for img in result_images] total_time = time.perf_counter() - request_start - print(f"[upload_images_blob] SUCCESS - task_id: {task_id}, " - f"total: {saved_count}, total_time: {total_time*1000:.1f}ms") + logger.info(f"[upload_images_blob] SUCCESS - task_id: {task_id}, " + f"total: {saved_count}, total_time: {total_time*1000:.1f}ms") return ImageUploadResponse( task_id=task_id, diff --git a/app/home/models.py b/app/home/models.py index 13c8508..026523d 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -4,12 +4,13 @@ Home 모듈 SQLAlchemy 모델 정의 이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다. - Project: 프로젝트(사용자 입력 이력) 관리 - Image: 업로드된 이미지 URL 관리 +- UserProject: User와 Project 간 M:N 관계 중계 테이블 """ from datetime import datetime from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import DateTime, Index, Integer, String, Text, func +from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base @@ -17,8 +18,125 @@ from app.database.session import Base if TYPE_CHECKING: from app.lyric.models import Lyric from app.song.models import Song + from app.user.models import User from app.video.models import Video +# ============================================================================= +# User-Project M:N 관계 중계 테이블 +# ============================================================================= +# +# 설계 의도: +# - User와 Project는 다대다(M:N) 관계입니다. +# - 한 사용자는 여러 프로젝트에 참여할 수 있습니다. +# - 한 프로젝트에는 여러 사용자가 참여할 수 있습니다. +# +# 중계 테이블 역할: +# - UserProject 테이블이 두 테이블 간의 관계를 연결합니다. +# - 각 레코드는 특정 사용자와 특정 프로젝트의 연결을 나타냅니다. +# - 추가 속성(role, joined_at)으로 관계의 메타데이터를 저장합니다. +# +# 외래키 설정: +# - user_id: User 테이블의 id를 참조 (ON DELETE CASCADE) +# - project_id: Project 테이블의 id를 참조 (ON DELETE CASCADE) +# - CASCADE 설정으로 부모 레코드 삭제 시 중계 레코드도 자동 삭제됩니다. +# +# 관계 방향: +# - User.projects → UserProject → Project (사용자가 참여한 프로젝트 목록) +# - Project.users → UserProject → User (프로젝트에 참여한 사용자 목록) +# ============================================================================= + + +class UserProject(Base): + """ + User-Project M:N 관계 중계 테이블 + + 사용자와 프로젝트 간의 다대다 관계를 관리합니다. + 한 사용자는 여러 프로젝트에 참여할 수 있고, + 한 프로젝트에는 여러 사용자가 참여할 수 있습니다. + + Attributes: + id: 고유 식별자 (자동 증가) + user_id: 사용자 외래키 (User.id 참조) + project_id: 프로젝트 외래키 (Project.id 참조) + role: 프로젝트 내 사용자 역할 (owner: 소유자, member: 멤버, viewer: 조회자) + joined_at: 프로젝트 참여 일시 + + 외래키 제약조건: + - user_id → user.id (ON DELETE CASCADE) + - project_id → project.id (ON DELETE CASCADE) + + 유니크 제약조건: + - (user_id, project_id) 조합은 유일해야 함 (중복 참여 방지) + """ + + __tablename__ = "user_project" + __table_args__ = ( + Index("idx_user_project_user_id", "user_id"), + Index("idx_user_project_project_id", "project_id"), + Index("idx_user_project_user_project", "user_id", "project_id", unique=True), + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + # 외래키: User 테이블 참조 + # - BigInteger 사용 (User.id가 BigInteger이므로 타입 일치 필요) + # - ondelete="CASCADE": User 삭제 시 연결된 UserProject 레코드도 삭제 + user_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + comment="사용자 외래키 (User.id 참조)", + ) + + # 외래키: Project 테이블 참조 + # - Integer 사용 (Project.id가 Integer이므로 타입 일치 필요) + # - ondelete="CASCADE": Project 삭제 시 연결된 UserProject 레코드도 삭제 + project_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("project.id", ondelete="CASCADE"), + nullable=False, + comment="프로젝트 외래키 (Project.id 참조)", + ) + + # ========================================================================== + # Relationships (관계 설정) + # ========================================================================== + # back_populates: 양방향 관계 설정 (User.user_projects, Project.user_projects) + # lazy="selectin": N+1 문제 방지를 위한 즉시 로딩 + # ========================================================================== + user: Mapped["User"] = relationship( + "User", + back_populates="user_projects", + lazy="selectin", + ) + + project: Mapped["Project"] = relationship( + "Project", + back_populates="user_projects", + lazy="selectin", + ) + + def __repr__(self) -> str: + return ( + f"" + ) + class Project(Base): """ @@ -39,6 +157,8 @@ class Project(Base): lyrics: 생성된 가사 목록 songs: 생성된 노래 목록 videos: 최종 영상 결과 목록 + user_projects: User와의 M:N 관계 (중계 테이블 통한 연결) + users: 프로젝트에 참여한 사용자 목록 (Association Proxy) """ __tablename__ = "project" @@ -123,6 +243,20 @@ class Project(Base): lazy="selectin", ) + # ========================================================================== + # User M:N 관계 (중계 테이블 UserProject 통한 연결) + # ========================================================================== + # back_populates: UserProject.project와 양방향 연결 + # cascade: Project 삭제 시 UserProject 레코드도 삭제 (User는 유지) + # lazy="selectin": N+1 문제 방지 + # ========================================================================== + user_projects: Mapped[List["UserProject"]] = relationship( + "UserProject", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + def __repr__(self) -> str: def truncate(value: str | None, max_len: int = 10) -> str: if value is None: diff --git a/app/home/tests/test_db.py b/app/home/tests/test_db.py index 04c8733..f85716d 100644 --- a/app/home/tests/test_db.py +++ b/app/home/tests/test_db.py @@ -2,6 +2,10 @@ import pytest from sqlalchemy import text from app.database.session import AsyncSessionLocal, engine +from app.utils.logger import get_logger + +# 로거 설정 +logger = get_logger("test_db") @pytest.mark.asyncio @@ -27,4 +31,4 @@ async def test_database_version(): result = await session.execute(text("SELECT VERSION()")) version = result.scalar() assert version is not None - print(f"MySQL Version: {version}") + logger.info(f"MySQL Version: {version}") diff --git a/app/home/worker/home_task.py b/app/home/worker/home_task.py index 69adfca..d352c16 100644 --- a/app/home/worker/home_task.py +++ b/app/home/worker/home_task.py @@ -11,8 +11,6 @@ from fastapi import UploadFile from app.utils.upload_blob_as_request import AzureBlobUploader -MEDIA_ROOT = Path("media") - async def save_upload_file(file: UploadFile, save_path: Path) -> None: """업로드 파일을 지정된 경로에 저장""" diff --git a/app/lyric/api/routers/v1/__init__.py b/app/lyric/api/routers/v1/__init__.py index e69de29..c50c16e 100644 --- a/app/lyric/api/routers/v1/__init__.py +++ b/app/lyric/api/routers/v1/__init__.py @@ -0,0 +1,3 @@ +""" +Lyric API v1 라우터 모듈 +""" diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index ff01be4..60576de 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -41,10 +41,13 @@ from app.lyric.schemas.lyric import ( ) from app.lyric.worker.lyric_task import generate_lyric_background from app.utils.chatgpt_prompt import ChatgptService +from app.utils.logger import get_logger from app.utils.pagination import PaginatedResponse, get_paginated from app.utils.prompts.prompts import lyric_prompt import traceback as tb +# 로거 설정 +logger = get_logger("lyric") router = APIRouter(prefix="/lyric", tags=["lyric"]) @@ -77,7 +80,7 @@ async def get_lyric_status_by_task_id( if status_info.status == "completed": # 완료 처리 """ - print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}") + logger.info(f"[get_lyric_status_by_task_id] START - task_id: {task_id}") result = await session.execute( select(Lyric) .where(Lyric.task_id == task_id) @@ -87,7 +90,7 @@ async def get_lyric_status_by_task_id( lyric = result.scalar_one_or_none() if not lyric: - print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}") + logger.warning(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.", @@ -99,7 +102,7 @@ async def get_lyric_status_by_task_id( "failed": "가사 생성에 실패했습니다.", } - print( + logger.info( f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}" ) return LyricStatusResponse( @@ -130,7 +133,7 @@ async def get_lyric_by_task_id( lyric = await get_lyric_by_task_id(session, task_id) """ - print(f"[get_lyric_by_task_id] START - task_id: {task_id}") + logger.info(f"[get_lyric_by_task_id] START - task_id: {task_id}") result = await session.execute( select(Lyric) .where(Lyric.task_id == task_id) @@ -140,19 +143,18 @@ async def get_lyric_by_task_id( lyric = result.scalar_one_or_none() if not lyric: - print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}") + logger.warning(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.", ) - print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}") + logger.info(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}") return LyricDetailResponse( id=lyric.id, task_id=lyric.task_id, project_id=lyric.project_id, status=lyric.status, - lyric_prompt=lyric.lyric_prompt, lyric_result=lyric.lyric_result, created_at=lyric.created_at, ) @@ -229,8 +231,8 @@ async def generate_lyric( task_id = request_body.task_id - print(f"[generate_lyric] ========== START ==========") - print( + logger.info(f"[generate_lyric] ========== START ==========") + logger.info( f"[generate_lyric] task_id: {task_id}, " f"customer_name: {request_body.customer_name}, " f"region: {request_body.region}" @@ -239,7 +241,7 @@ async def generate_lyric( try: # ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ========== step1_start = time.perf_counter() - print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...") + logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...") # service = ChatgptService( # customer_name=request_body.customer_name, @@ -279,11 +281,11 @@ async def generate_lyric( estimated_prompt = lyric_prompt.build_prompt(lyric_input_data) step1_elapsed = (time.perf_counter() - step1_start) * 1000 - #print(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") + #logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") # ========== Step 2: Project 테이블에 데이터 저장 ========== step2_start = time.perf_counter() - print(f"[generate_lyric] Step 2: Project 저장...") + logger.debug(f"[generate_lyric] Step 2: Project 저장...") project = Project( store_name=request_body.customer_name, @@ -297,11 +299,11 @@ async def generate_lyric( await session.refresh(project) step2_elapsed = (time.perf_counter() - step2_start) * 1000 - print(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)") + logger.debug(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)") # ========== Step 3: Lyric 테이블에 데이터 저장 ========== step3_start = time.perf_counter() - print(f"[generate_lyric] Step 3: Lyric 저장 (processing)...") + logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...") estimated_prompt = lyric_prompt.build_prompt(lyric_input_data) lyric = Lyric( @@ -317,11 +319,11 @@ async def generate_lyric( await session.refresh(lyric) step3_elapsed = (time.perf_counter() - step3_start) * 1000 - print(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)") + logger.debug(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)") # ========== Step 4: 백그라운드 태스크 스케줄링 ========== step4_start = time.perf_counter() - print(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...") + logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...") background_tasks.add_task( generate_lyric_background, @@ -331,17 +333,17 @@ async def generate_lyric( ) step4_elapsed = (time.perf_counter() - step4_start) * 1000 - print(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)") + logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)") # ========== 완료 ========== total_elapsed = (time.perf_counter() - request_start) * 1000 - print(f"[generate_lyric] ========== COMPLETE ==========") - print(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms") - print(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms") - print(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms") - print(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms") - print(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms") - print(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)") + logger.info(f"[generate_lyric] ========== COMPLETE ==========") + logger.info(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms") + logger.debug(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms") + logger.debug(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms") + logger.debug(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms") + logger.debug(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms") + logger.debug(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)") # 5. 즉시 응답 반환 return GenerateLyricResponse( @@ -354,7 +356,7 @@ async def generate_lyric( except Exception as e: elapsed = (time.perf_counter() - request_start) * 1000 - print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)") + logger.error(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)") await session.rollback() return GenerateLyricResponse( success=False, diff --git a/app/lyric/dependencies.py b/app/lyric/dependencies.py index d03c265..e69de29 100644 --- a/app/lyric/dependencies.py +++ b/app/lyric/dependencies.py @@ -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)] diff --git a/app/lyric/schemas/lyric.py b/app/lyric/schemas/lyric.py index d57b7cd..37cb76a 100644 --- a/app/lyric/schemas/lyric.py +++ b/app/lyric/schemas/lyric.py @@ -140,7 +140,6 @@ class LyricDetailResponse(BaseModel): "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", "project_id": 1, "status": "completed", - "lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...", "lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요", "created_at": "2024-01-15T12:00:00", } @@ -151,7 +150,6 @@ class LyricDetailResponse(BaseModel): task_id: str = Field(..., description="작업 고유 식별자") project_id: int = Field(..., description="프로젝트 ID") status: str = Field(..., description="처리 상태") - lyric_prompt: str = Field(..., description="가사 생성 프롬프트") lyric_result: Optional[str] = Field(None, description="생성된 가사") created_at: Optional[datetime] = Field(None, description="생성 일시") diff --git a/app/lyric/worker/lyric_task.py b/app/lyric/worker/lyric_task.py index 8b53b0d..f956164 100644 --- a/app/lyric/worker/lyric_task.py +++ b/app/lyric/worker/lyric_task.py @@ -4,7 +4,6 @@ Lyric Background Tasks 가사 생성 관련 백그라운드 태스크를 정의합니다. """ -import logging import traceback from sqlalchemy import select @@ -14,9 +13,10 @@ from app.database.session import BackgroundSessionLocal from app.lyric.models import Lyric from app.utils.chatgpt_prompt import ChatgptService from app.utils.prompts.prompts import Prompt +from app.utils.logger import get_logger # 로거 설정 -logger = logging.getLogger(__name__) +logger = get_logger("lyric") async def _update_lyric_status( @@ -50,20 +50,16 @@ async def _update_lyric_status( lyric.lyric_result = result await session.commit() logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}") - print(f"[Lyric] Status updated - task_id: {task_id}, status: {status}") return True else: logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}") - print(f"[Lyric] NOT FOUND in DB - task_id: {task_id}") return False except SQLAlchemyError as e: logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}") - print(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}") return False except Exception as e: logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}") - print(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}") return False @@ -83,15 +79,15 @@ async def generate_lyric_background( task_start = time.perf_counter() logger.info(f"[generate_lyric_background] START - task_id: {task_id}") - print(f"[generate_lyric_background] ========== START ==========") - print(f"[generate_lyric_background] task_id: {task_id}") - print(f"[generate_lyric_background] language: {lyric_input_data['language']}") - #print(f"[generate_lyric_background] prompt length: {len(prompt)}자") + logger.debug(f"[generate_lyric_background] ========== START ==========") + logger.debug(f"[generate_lyric_background] task_id: {task_id}") + logger.debug(f"[generate_lyric_background] language: {lyric_input_data['language']}") + #logger.debug(f"[generate_lyric_background] prompt length: {len(prompt)}자") try: # ========== Step 1: ChatGPT 서비스 초기화 ========== step1_start = time.perf_counter() - print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...") + logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...") # service = ChatgptService( # customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값 @@ -103,48 +99,43 @@ async def generate_lyric_background( chatgpt = ChatgptService() step1_elapsed = (time.perf_counter() - step1_start) * 1000 - print(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)") + logger.debug(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)") # ========== Step 2: ChatGPT API 호출 (가사 생성) ========== step2_start = time.perf_counter() logger.info(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작 - task_id: {task_id}") - print(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작...") + logger.debug(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작...") #result = await service.generate(prompt=prompt) result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data) result = result_response['lyric'] step2_elapsed = (time.perf_counter() - step2_start) * 1000 logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)") - print(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)") # ========== Step 3: DB 상태 업데이트 ========== step3_start = time.perf_counter() - print(f"[generate_lyric_background] Step 3: DB 상태 업데이트...") + logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...") await _update_lyric_status(task_id, "completed", result) step3_elapsed = (time.perf_counter() - step3_start) * 1000 - print(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)") + logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)") # ========== 완료 ========== total_elapsed = (time.perf_counter() - task_start) * 1000 logger.info(f"[generate_lyric_background] SUCCESS - task_id: {task_id}, 총 소요시간: {total_elapsed:.1f}ms") - print(f"[generate_lyric_background] ========== SUCCESS ==========") - print(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms") - print(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms") - print(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms") - print(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms") + logger.debug(f"[generate_lyric_background] ========== SUCCESS ==========") + logger.debug(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms") + logger.debug(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms") + logger.debug(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms") + logger.debug(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms") except SQLAlchemyError as e: elapsed = (time.perf_counter() - task_start) * 1000 - logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)") - print(f"[generate_lyric_background] DB ERROR - {e} ({elapsed:.1f}ms)") - traceback.print_exc() + logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True) await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}") except Exception as e: elapsed = (time.perf_counter() - task_start) * 1000 - logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)") - print(f"[generate_lyric_background] EXCEPTION - {e} ({elapsed:.1f}ms)") - traceback.print_exc() + logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True) await _update_lyric_status(task_id, "failed", f"Error: {str(e)}") diff --git a/app/song/api/routers/v1/__init__.py b/app/song/api/routers/v1/__init__.py index e69de29..5547af4 100644 --- a/app/song/api/routers/v1/__init__.py +++ b/app/song/api/routers/v1/__init__.py @@ -0,0 +1,3 @@ +""" +Song API v1 라우터 모듈 +""" diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 50ad447..a29b1b6 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -32,11 +32,13 @@ from app.song.schemas.song_schema import ( PollingSongResponse, SongListItem, ) +from app.utils.logger import get_logger from app.utils.pagination import PaginatedResponse from app.utils.suno import SunoService +logger = get_logger("song") -router = APIRouter(prefix="/song", tags=["song"]) +router = APIRouter(prefix="/song", tags=["Song"]) @router.post( @@ -99,7 +101,7 @@ async def generate_song( from app.database.session import AsyncSessionLocal request_start = time.perf_counter() - print( + logger.info( f"[generate_song] START - task_id: {task_id}, " f"genre: {request_body.genre}, language: {request_body.language}" ) @@ -124,7 +126,7 @@ async def generate_song( project = project_result.scalar_one_or_none() if not project: - print(f"[generate_song] Project NOT FOUND - task_id: {task_id}") + logger.warning(f"[generate_song] Project NOT FOUND - task_id: {task_id}") raise HTTPException( status_code=404, detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", @@ -141,7 +143,7 @@ async def generate_song( lyric = lyric_result.scalar_one_or_none() if not lyric: - print(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}") + logger.warning(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}") raise HTTPException( status_code=404, detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", @@ -149,7 +151,7 @@ async def generate_song( lyric_id = lyric.id query_time = time.perf_counter() - print( + logger.info( f"[generate_song] Queries completed - task_id: {task_id}, " f"project_id: {project_id}, lyric_id: {lyric_id}, " f"elapsed: {(query_time - request_start)*1000:.1f}ms" @@ -174,7 +176,7 @@ async def generate_song( song_id = song.id stage1_time = time.perf_counter() - print( + logger.info( f"[generate_song] Stage 1 DONE - Song saved - " f"task_id: {task_id}, song_id: {song_id}, " f"elapsed: {(stage1_time - request_start)*1000:.1f}ms" @@ -184,7 +186,7 @@ async def generate_song( except HTTPException: raise except Exception as e: - print( + logger.error( f"[generate_song] Stage 1 EXCEPTION - " f"task_id: {task_id}, error: {type(e).__name__}: {e}" ) @@ -203,7 +205,7 @@ async def generate_song( suno_task_id: str | None = None try: - print(f"[generate_song] Stage 2 START - Suno API - task_id: {task_id}") + logger.info(f"[generate_song] Stage 2 START - Suno API - task_id: {task_id}") suno_service = SunoService() suno_task_id = await suno_service.generate( prompt=request_body.lyrics, @@ -211,14 +213,14 @@ async def generate_song( ) stage2_time = time.perf_counter() - print( + logger.info( f"[generate_song] Stage 2 DONE - task_id: {task_id}, " f"suno_task_id: {suno_task_id}, " f"elapsed: {(stage2_time - stage2_start)*1000:.1f}ms" ) except Exception as e: - print( + logger.error( f"[generate_song] Stage 2 EXCEPTION - Suno API failed - " f"task_id: {task_id}, error: {type(e).__name__}: {e}" ) @@ -244,7 +246,7 @@ async def generate_song( # 3단계: suno_task_id 업데이트 (새 세션으로 빠르게 처리) # ========================================================================== stage3_start = time.perf_counter() - print(f"[generate_song] Stage 3 START - DB update - task_id: {task_id}") + logger.info(f"[generate_song] Stage 3 START - DB update - task_id: {task_id}") try: async with AsyncSessionLocal() as update_session: @@ -258,11 +260,11 @@ async def generate_song( stage3_time = time.perf_counter() total_time = stage3_time - request_start - print( + logger.info( f"[generate_song] Stage 3 DONE - task_id: {task_id}, " f"elapsed: {(stage3_time - stage3_start)*1000:.1f}ms" ) - print( + logger.info( f"[generate_song] SUCCESS - task_id: {task_id}, " f"suno_task_id: {suno_task_id}, " f"total_time: {total_time*1000:.1f}ms" @@ -277,7 +279,7 @@ async def generate_song( ) except Exception as e: - print( + logger.error( f"[generate_song] Stage 3 EXCEPTION - " f"task_id: {task_id}, error: {type(e).__name__}: {e}" ) @@ -341,12 +343,12 @@ async def get_song_status( Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로, song_result_url을 Blob URL로 업데이트합니다. """ - print(f"[get_song_status] START - suno_task_id: {suno_task_id}") + logger.info(f"[get_song_status] START - suno_task_id: {suno_task_id}") try: suno_service = SunoService() result = await suno_service.get_task_status(suno_task_id) parsed_response = suno_service.parse_status_response(result) - print(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}") + logger.info(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}") # SUCCESS 상태인 경우 첫 번째 클립 정보를 DB에 직접 저장 if parsed_response.status == "SUCCESS" and parsed_response.clips: @@ -354,7 +356,7 @@ async def get_song_status( first_clip = parsed_response.clips[0] audio_url = first_clip.audio_url clip_duration = first_clip.duration - print(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_duration}") + logger.debug(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_duration}") if audio_url: # suno_task_id로 Song 조회 @@ -373,17 +375,17 @@ async def get_song_status( if clip_duration is not None: song.duration = clip_duration await session.commit() - print(f"[get_song_status] Song updated - suno_task_id: {suno_task_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}") + logger.info(f"[get_song_status] Song updated - suno_task_id: {suno_task_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}") elif song and song.status == "completed": - print(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}") + logger.info(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}") - print(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}") + logger.info(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}") return parsed_response except Exception as e: import traceback - print(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") + logger.error(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") return PollingSongResponse( success=False, status="error", @@ -438,7 +440,7 @@ async def download_song( session: AsyncSession = Depends(get_session), ) -> DownloadSongResponse: """task_id로 Song 상태를 polling하고 completed 시 Project 정보와 노래 URL을 반환합니다.""" - print(f"[download_song] START - task_id: {task_id}") + logger.info(f"[download_song] START - task_id: {task_id}") try: # task_id로 Song 조회 (여러 개 있을 경우 가장 최근 것 선택) song_result = await session.execute( @@ -450,7 +452,7 @@ async def download_song( song = song_result.scalar_one_or_none() if not song: - print(f"[download_song] Song NOT FOUND - task_id: {task_id}") + logger.warning(f"[download_song] Song NOT FOUND - task_id: {task_id}") return DownloadSongResponse( success=False, status="not_found", @@ -458,11 +460,11 @@ async def download_song( error_message="Song not found", ) - print(f"[download_song] Song found - task_id: {task_id}, status: {song.status}") + logger.info(f"[download_song] Song found - task_id: {task_id}, status: {song.status}") # processing 상태인 경우 if song.status == "processing": - print(f"[download_song] PROCESSING - task_id: {task_id}") + logger.info(f"[download_song] PROCESSING - task_id: {task_id}") return DownloadSongResponse( success=True, status="processing", @@ -472,7 +474,7 @@ async def download_song( # failed 상태인 경우 if song.status == "failed": - print(f"[download_song] FAILED - task_id: {task_id}") + logger.warning(f"[download_song] FAILED - task_id: {task_id}") return DownloadSongResponse( success=False, status="failed", @@ -487,7 +489,7 @@ async def download_song( ) project = project_result.scalar_one_or_none() - print(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}") + logger.info(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}") return DownloadSongResponse( success=True, status="completed", @@ -502,7 +504,7 @@ async def download_song( ) except Exception as e: - print(f"[download_song] EXCEPTION - task_id: {task_id}, error: {e}") + logger.error(f"[download_song] EXCEPTION - task_id: {task_id}, error: {e}") return DownloadSongResponse( success=False, status="error", @@ -550,7 +552,7 @@ async def get_songs( pagination: PaginationParams = Depends(get_pagination_params), ) -> PaginatedResponse[SongListItem]: """완료된 노래 목록을 페이지네이션하여 반환합니다.""" - print(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}") + logger.info(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}") try: offset = (pagination.page - 1) * pagination.page_size @@ -622,14 +624,14 @@ async def get_songs( page_size=pagination.page_size, ) - print( + logger.info( f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, " f"page_size: {pagination.page_size}, items_count: {len(items)}" ) return response except Exception as e: - print(f"[get_songs] EXCEPTION - error: {e}") + logger.error(f"[get_songs] EXCEPTION - error: {e}") raise HTTPException( status_code=500, detail=f"노래 목록 조회에 실패했습니다: {str(e)}", diff --git a/app/song/dependencies.py b/app/song/dependencies.py index d03c265..e69de29 100644 --- a/app/song/dependencies.py +++ b/app/song/dependencies.py @@ -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)] diff --git a/app/song/services/song.py b/app/song/services/song.py index fd2c6c0..9955e6b 100644 --- a/app/song/services/song.py +++ b/app/song/services/song.py @@ -6,6 +6,7 @@ from fastapi.exceptions import HTTPException from sqlalchemy import Connection, text from sqlalchemy.exc import SQLAlchemyError +from app.utils.logger import get_logger from app.lyrics.schemas.lyrics_schema import ( AttributeData, PromptTemplateData, @@ -15,6 +16,8 @@ from app.lyrics.schemas.lyrics_schema import ( ) from app.utils.chatgpt_prompt import chatgpt_api +logger = get_logger("song") + async def get_store_info(conn: Connection) -> List[StoreData]: try: @@ -38,13 +41,13 @@ async def get_store_info(conn: Connection) -> List[StoreData]: result.close() return all_store_info except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemyError in get_store_info: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_store_info: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -69,13 +72,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]: result.close() return all_attribute except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemyError in get_attribute: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_attribute: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -100,13 +103,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]: result.close() return all_attribute except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemyError in get_attribute (duplicate): {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_attribute (duplicate): {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -132,13 +135,13 @@ async def get_sample_song(conn: Connection) -> List[SongSampleData]: result.close() return all_sample_song except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemyError in get_sample_song: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_sample_song: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -162,13 +165,13 @@ async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]: result.close() return all_prompt_template except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemyError in get_prompt_template: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_prompt_template: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -192,13 +195,13 @@ async def get_song_result(conn: Connection) -> List[PromptTemplateData]: result.close() return all_prompt_template except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemyError in get_song_result (prompt_template): {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_song_result (prompt_template): {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -210,11 +213,11 @@ async def make_song_result(request: Request, conn: Connection): # 1. Form 데이터 파싱 form_data = await SongFormData.from_form(request) - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"Lyrics IDs: {form_data.lyrics_ids}") - print(f"Prompt IDs: {form_data.prompts}") - print(f"{'=' * 60}\n") + logger.info(f"{'=' * 60}") + logger.info(f"Store ID: {form_data.store_id}") + logger.info(f"Lyrics IDs: {form_data.lyrics_ids}") + logger.info(f"Prompt IDs: {form_data.prompts}") + logger.info(f"{'=' * 60}") # 2. Store 정보 조회 store_query = """ @@ -243,7 +246,7 @@ async def make_song_result(request: Request, conn: Connection): ) store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") + logger.info(f"Store: {store_info.store_name}") # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 @@ -251,7 +254,7 @@ async def make_song_result(request: Request, conn: Connection): combined_sample_song = None if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") lyrics_query = """ SELECT sample_song FROM song_sample @@ -270,11 +273,11 @@ async def make_song_result(request: Request, conn: Connection): combined_sample_song = "\n\n".join( [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료") else: - print("샘플 가사가 비어있습니다") + logger.info("샘플 가사가 비어있습니다") else: - print("선택된 lyrics가 없습니다") + logger.info("선택된 lyrics가 없습니다") # 5. 템플릿 가져오기 if not form_data.prompts: @@ -283,7 +286,7 @@ async def make_song_result(request: Request, conn: Connection): detail="프롬프트 ID가 필요합니다", ) - print("템플릿 가져오기") + logger.info("템플릿 가져오기") prompts_query = """ SELECT * FROM prompt_template WHERE id=:id; @@ -310,7 +313,7 @@ async def make_song_result(request: Request, conn: Connection): ) prompt = prompts_info[0] - print(f"Prompt Template: {prompt.prompt}") + logger.debug(f"Prompt Template: {prompt.prompt}") # ✅ 6. 프롬프트 조합 updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( @@ -329,7 +332,7 @@ async def make_song_result(request: Request, conn: Connection): {combined_sample_song} """ - print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") + logger.debug(f"[업데이트된 프롬프트]\n{updated_prompt}") # 7. 모델에게 요청 generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) @@ -348,13 +351,12 @@ async def make_song_result(request: Request, conn: Connection): 전체 글자 수 (공백 포함): {total_chars_with_space}자 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" - print("=" * 40) - print("[translate:form_data.attributes_str:] ", form_data.attributes_str) - print("[translate:total_chars_with_space:] ", total_chars_with_space) - print("[translate:total_chars_without_space:] ", total_chars_without_space) - print("[translate:final_lyrics:]") - print(final_lyrics) - print("=" * 40) + logger.debug("=" * 40) + logger.debug(f"[translate:form_data.attributes_str:] {form_data.attributes_str}") + logger.debug(f"[translate:total_chars_with_space:] {total_chars_with_space}") + logger.debug(f"[translate:total_chars_without_space:] {total_chars_without_space}") + logger.debug(f"[translate:final_lyrics:]\n{final_lyrics}") + logger.debug("=" * 40) # 8. DB 저장 insert_query = """ @@ -396,13 +398,13 @@ async def make_song_result(request: Request, conn: Connection): await conn.execute(text(insert_query), insert_params) await conn.commit() - print("결과 저장 완료") + logger.info("make_song_result 결과 저장 완료") - print("\n전체 결과 조회 중...") + logger.info("make_song_result 전체 결과 조회 중...") # 9. 생성 결과 가져오기 (created_at 역순) select_query = """ - SELECT * FROM song_results_all + SELECT * FROM song_results_all ORDER BY created_at DESC; """ @@ -430,26 +432,20 @@ async def make_song_result(request: Request, conn: Connection): for row in all_results.fetchall() ] - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + logger.info(f"make_song_result 전체 {len(results_list)}개의 결과 조회 완료") return results_list except HTTPException: raise except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"make_song_result Database Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="데이터베이스 연결에 문제가 발생했습니다.", ) except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"make_song_result Unexpected Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="서비스 처리 중 오류가 발생했습니다.", @@ -490,25 +486,19 @@ async def get_song_result(conn: Connection): # 반환 타입 수정 for row in all_results.fetchall() ] - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + logger.info(f"get_song_result 전체 {len(results_list)}개의 결과 조회 완료") return results_list except HTTPException: # HTTPException은 그대로 raise raise except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"get_song_result Database Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="데이터베이스 연결에 문제가 발생했습니다.", ) except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"get_song_result Unexpected Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="서비스 처리 중 오류가 발생했습니다.", @@ -520,9 +510,9 @@ async def make_automation(request: Request, conn: Connection): # 1. Form 데이터 파싱 form_data = await SongFormData.from_form(request) - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"{'=' * 60}\n") + logger.info(f"{'=' * 60}") + logger.info(f"make_automation Store ID: {form_data.store_id}") + logger.info(f"{'=' * 60}") # 2. Store 정보 조회 store_query = """ @@ -551,7 +541,7 @@ async def make_automation(request: Request, conn: Connection): ) store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") + logger.info(f"make_automation Store: {store_info.store_name}") # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 attribute_query = """ @@ -596,13 +586,13 @@ async def make_automation(request: Request, conn: Connection): # 최종 문자열 생성 formatted_attributes = "\n".join(formatted_pairs) - print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") + logger.debug(f"[포맷팅된 문자열 속성 정보]\n{formatted_attributes}") else: - print("속성 데이터가 없습니다") + logger.info("속성 데이터가 없습니다") formatted_attributes = "" # 4. 템플릿 가져오기 - print("템플릿 가져오기 (ID=1)") + logger.info("템플릿 가져오기 (ID=1)") prompts_query = """ SELECT * FROM prompt_template WHERE id=1; @@ -624,7 +614,7 @@ async def make_automation(request: Request, conn: Connection): prompt=row[2], ) - print(f"Prompt Template: {prompt.prompt}") + logger.debug(f"Prompt Template: {prompt.prompt}") # 5. 템플릿 조합 @@ -635,17 +625,17 @@ async def make_automation(request: Request, conn: Connection): description=store_info.store_info or "", ) - print("\n" + "=" * 80) - print("업데이트된 프롬프트") - print("=" * 80) - print(updated_prompt) - print("=" * 80 + "\n") + logger.debug("=" * 80) + logger.debug("업데이트된 프롬프트") + logger.debug("=" * 80) + logger.debug(updated_prompt) + logger.debug("=" * 80) # 4. Sample Song 조회 및 결합 combined_sample_song = None if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") lyrics_query = """ SELECT sample_song FROM song_sample @@ -664,14 +654,14 @@ async def make_automation(request: Request, conn: Connection): combined_sample_song = "\n\n".join( [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료") else: - print("샘플 가사가 비어있습니다") + logger.info("샘플 가사가 비어있습니다") else: - print("선택된 lyrics가 없습니다") + logger.info("선택된 lyrics가 없습니다") # 1. song_sample 테이블의 모든 ID 조회 - print("\n[샘플 가사 랜덤 선택]") + logger.info("[샘플 가사 랜덤 선택]") all_ids_query = """ SELECT id FROM song_sample; @@ -679,7 +669,7 @@ async def make_automation(request: Request, conn: Connection): ids_result = await conn.execute(text(all_ids_query)) all_ids = [row.id for row in ids_result.fetchall()] - print(f"전체 샘플 가사 개수: {len(all_ids)}개") + logger.info(f"전체 샘플 가사 개수: {len(all_ids)}개") # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) combined_sample_song = None @@ -689,7 +679,7 @@ async def make_automation(request: Request, conn: Connection): sample_count = min(3, len(all_ids)) selected_ids = random.sample(all_ids, sample_count) - print(f"랜덤 선택된 ID: {selected_ids}") + logger.debug(f"랜덤 선택된 ID: {selected_ids}") # 3. 선택된 ID로 샘플 가사 조회 lyrics_query = """ @@ -710,11 +700,11 @@ async def make_automation(request: Request, conn: Connection): combined_sample_song = "\n\n".join( [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료") else: - print("샘플 가사가 비어있습니다") + logger.info("샘플 가사가 비어있습니다") else: - print("song_sample 테이블에 데이터가 없습니다") + logger.info("song_sample 테이블에 데이터가 없습니다") # 5. 프롬프트에 샘플 가사 추가 if combined_sample_song: @@ -726,11 +716,11 @@ async def make_automation(request: Request, conn: Connection): {combined_sample_song} """ - print("샘플 가사 정보가 프롬프트에 추가되었습니다") + logger.info("샘플 가사 정보가 프롬프트에 추가되었습니다") else: - print("샘플 가사가 없어 기본 프롬프트만 사용합니다") + logger.info("샘플 가사가 없어 기본 프롬프트만 사용합니다") - print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") + logger.info(f"[최종 프롬프트 길이: {len(updated_prompt)} 자]") # 7. 모델에게 요청 generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) @@ -763,10 +753,9 @@ async def make_automation(request: Request, conn: Connection): :sample_song, :result_song, NOW() ); """ - print("\n[insert_params 선택된 속성 확인]") - print(f"Categories: {selected_categories}") - print(f"Values: {selected_values}") - print() + logger.debug("[insert_params 선택된 속성 확인]") + logger.debug(f"Categories: {selected_categories}") + logger.debug(f"Values: {selected_values}") # attr_category, attr_value insert_params = { @@ -792,9 +781,9 @@ async def make_automation(request: Request, conn: Connection): await conn.execute(text(insert_query), insert_params) await conn.commit() - print("결과 저장 완료") + logger.info("make_automation 결과 저장 완료") - print("\n전체 결과 조회 중...") + logger.info("make_automation 전체 결과 조회 중...") # 9. 생성 결과 가져오기 (created_at 역순) select_query = """ @@ -826,26 +815,20 @@ async def make_automation(request: Request, conn: Connection): for row in all_results.fetchall() ] - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + logger.info(f"make_automation 전체 {len(results_list)}개의 결과 조회 완료") return results_list except HTTPException: raise except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"make_automation Database Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="데이터베이스 연결에 문제가 발생했습니다.", ) except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"make_automation Unexpected Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="서비스 처리 중 오류가 발생했습니다.", diff --git a/app/song/worker/song_task.py b/app/song/worker/song_task.py index ec663e9..a264145 100644 --- a/app/song/worker/song_task.py +++ b/app/song/worker/song_task.py @@ -4,7 +4,6 @@ Song Background Tasks 노래 생성 관련 백그라운드 태스크를 정의합니다. """ -import logging import traceback from datetime import date from pathlib import Path @@ -17,11 +16,12 @@ from sqlalchemy.exc import SQLAlchemyError from app.database.session import BackgroundSessionLocal from app.song.models import Song from app.utils.common import generate_task_id +from app.utils.logger import get_logger from app.utils.upload_blob_as_request import AzureBlobUploader from config import prj_settings # 로거 설정 -logger = logging.getLogger(__name__) +logger = get_logger("song") # HTTP 요청 설정 REQUEST_TIMEOUT = 120.0 # 초 @@ -73,20 +73,16 @@ async def _update_song_status( song.duration = duration await session.commit() logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}") - print(f"[Song] Status updated - task_id: {task_id}, status: {status}") return True else: logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}") - print(f"[Song] NOT FOUND in DB - task_id: {task_id}") return False except SQLAlchemyError as e: logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}") - print(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}") return False except Exception as e: logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}") - print(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}") return False @@ -104,14 +100,12 @@ async def _download_audio(url: str, task_id: str) -> bytes: httpx.HTTPError: 다운로드 실패 시 """ logger.info(f"[Download] Downloading - task_id: {task_id}") - print(f"[Download] Downloading - task_id: {task_id}") async with httpx.AsyncClient() as client: response = await client.get(url, timeout=REQUEST_TIMEOUT) response.raise_for_status() logger.info(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") - print(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") return response.content @@ -128,7 +122,6 @@ async def download_and_save_song( store_name: 저장할 파일명에 사용할 업체명 """ logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}") - print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}") try: # 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3 @@ -146,11 +139,9 @@ async def download_and_save_song( media_dir.mkdir(parents=True, exist_ok=True) file_path = media_dir / file_name logger.info(f"[download_and_save_song] Directory created - path: {file_path}") - print(f"[download_and_save_song] Directory created - path: {file_path}") # 오디오 파일 다운로드 logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}") - print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}") content = await _download_audio(audio_url, task_id) @@ -158,36 +149,27 @@ async def download_and_save_song( await f.write(content) logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}") - print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}") # 프론트엔드에서 접근 가능한 URL 생성 relative_path = f"/media/song/{today}/{unique_id}/{file_name}" base_url = f"http://{prj_settings.PROJECT_DOMAIN}" file_url = f"{base_url}{relative_path}" logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}") - print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}") # Song 테이블 업데이트 await _update_song_status(task_id, "completed", file_url) logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}") - print(f"[download_and_save_song] SUCCESS - task_id: {task_id}") except httpx.HTTPError as e: - logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") - print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True) await _update_song_status(task_id, "failed") except SQLAlchemyError as e: - logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}") - print(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True) await _update_song_status(task_id, "failed") except Exception as e: - logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}") - print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True) await _update_song_status(task_id, "failed") @@ -204,7 +186,6 @@ async def download_and_upload_song_to_blob( store_name: 저장할 파일명에 사용할 업체명 """ logger.info(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}") - print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}") temp_file_path: Path | None = None try: @@ -220,11 +201,9 @@ async def download_and_upload_song_to_blob( temp_dir.mkdir(parents=True, exist_ok=True) temp_file_path = temp_dir / file_name logger.info(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}") - print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}") # 오디오 파일 다운로드 logger.info(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}") - print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}") content = await _download_audio(audio_url, task_id) @@ -232,7 +211,6 @@ async def download_and_upload_song_to_blob( await f.write(content) logger.info(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") - print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") # Azure Blob Storage에 업로드 uploader = AzureBlobUploader(task_id=task_id) @@ -244,29 +222,21 @@ async def download_and_upload_song_to_blob( # SAS 토큰이 제외된 public_url 사용 blob_url = uploader.public_url logger.info(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") - print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") # Song 테이블 업데이트 await _update_song_status(task_id, "completed", blob_url) logger.info(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}") - print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}") except httpx.HTTPError as e: - logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") - print(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True) await _update_song_status(task_id, "failed") except SQLAlchemyError as e: - logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}") - print(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True) await _update_song_status(task_id, "failed") except Exception as e: - logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") - print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True) await _update_song_status(task_id, "failed") finally: @@ -275,10 +245,8 @@ async def download_and_upload_song_to_blob( try: temp_file_path.unlink() logger.info(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}") - print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}") except Exception as e: logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}") - print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}") # 임시 디렉토리 삭제 시도 temp_dir = Path("media") / "temp" / task_id @@ -304,7 +272,6 @@ async def download_and_upload_song_by_suno_task_id( duration: 노래 재생 시간 (초) """ logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}") - print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}") temp_file_path: Path | None = None task_id: str | None = None @@ -321,12 +288,10 @@ async def download_and_upload_song_by_suno_task_id( if not song: logger.warning(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}") - print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}") return task_id = song.task_id logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}") - print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}") # 파일명에 사용할 수 없는 문자 제거 safe_store_name = "".join( @@ -340,11 +305,9 @@ async def download_and_upload_song_by_suno_task_id( temp_dir.mkdir(parents=True, exist_ok=True) temp_file_path = temp_dir / file_name logger.info(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}") - print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}") # 오디오 파일 다운로드 logger.info(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}") - print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}") content = await _download_audio(audio_url, task_id) @@ -352,7 +315,6 @@ async def download_and_upload_song_by_suno_task_id( await f.write(content) logger.info(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}") - print(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}") # Azure Blob Storage에 업로드 uploader = AzureBlobUploader(task_id=task_id) @@ -364,7 +326,6 @@ async def download_and_upload_song_by_suno_task_id( # SAS 토큰이 제외된 public_url 사용 blob_url = uploader.public_url logger.info(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}") - print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}") # Song 테이블 업데이트 await _update_song_status( @@ -375,26 +336,19 @@ async def download_and_upload_song_by_suno_task_id( duration=duration, ) logger.info(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}") - print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}") except httpx.HTTPError as e: - logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}") - print(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True) if task_id: await _update_song_status(task_id, "failed", suno_task_id=suno_task_id) except SQLAlchemyError as e: - logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}") - print(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True) if task_id: await _update_song_status(task_id, "failed", suno_task_id=suno_task_id) except Exception as e: - logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") - print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}", exc_info=True) if task_id: await _update_song_status(task_id, "failed", suno_task_id=suno_task_id) @@ -404,10 +358,8 @@ async def download_and_upload_song_by_suno_task_id( try: temp_file_path.unlink() logger.info(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}") - print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}") except Exception as e: logger.warning(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}") - print(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}") # 임시 디렉토리 삭제 시도 if task_id: diff --git a/app/user/__init__.py b/app/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/api/__init__.py b/app/user/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/api/routers/__init__.py b/app/user/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/api/routers/v1/__init__.py b/app/user/api/routers/v1/__init__.py new file mode 100644 index 0000000..151d252 --- /dev/null +++ b/app/user/api/routers/v1/__init__.py @@ -0,0 +1,3 @@ +""" +User API v1 라우터 모듈 +""" diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py new file mode 100644 index 0000000..7f6ce56 --- /dev/null +++ b/app/user/api/routers/v1/auth.py @@ -0,0 +1,166 @@ +""" +인증 API 라우터 + +카카오 로그인, 토큰 갱신, 로그아웃, 내 정보 조회 엔드포인트를 제공합니다. +""" + +from typing import Optional + +from fastapi import APIRouter, Depends, Header, Request, status +from fastapi.responses import Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session +from app.user.dependencies import get_current_user +from app.user.models import User +from app.user.schemas.user_schema import ( + AccessTokenResponse, + KakaoCallbackRequest, + KakaoLoginResponse, + LoginResponse, + RefreshTokenRequest, + UserResponse, +) +from app.user.services import auth_service, kakao_client + +router = APIRouter(prefix="/auth", tags=["Auth"]) + + +@router.get( + "/kakao/login", + response_model=KakaoLoginResponse, + summary="카카오 로그인 URL 요청", + description="카카오 OAuth 인증 페이지 URL을 반환합니다.", +) +async def kakao_login() -> KakaoLoginResponse: + """ + 카카오 로그인 URL 반환 + + 프론트엔드에서 이 URL로 사용자를 리다이렉트하면 + 카카오 로그인 페이지가 표시됩니다. + """ + auth_url = kakao_client.get_authorization_url() + return KakaoLoginResponse(auth_url=auth_url) + + +@router.post( + "/kakao/callback", + response_model=LoginResponse, + summary="카카오 로그인 콜백", + description="카카오 인가 코드를 받아 로그인/가입을 처리하고 JWT 토큰을 발급합니다.", +) +async def kakao_callback( + request: Request, + body: KakaoCallbackRequest, + session: AsyncSession = Depends(get_session), + user_agent: Optional[str] = Header(None, alias="User-Agent"), +) -> LoginResponse: + """ + 카카오 로그인 콜백 + + 카카오 로그인 성공 후 발급받은 인가 코드로 + JWT 토큰을 발급합니다. + + 신규 사용자인 경우 자동으로 회원가입이 처리됩니다. + """ + # 클라이언트 IP 추출 + ip_address = request.client.host if request.client else None + + # X-Forwarded-For 헤더 확인 (프록시/로드밸런서 뒤에 있는 경우) + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + ip_address = forwarded_for.split(",")[0].strip() + + return await auth_service.kakao_login( + code=body.code, + session=session, + user_agent=user_agent, + ip_address=ip_address, + ) + + +@router.post( + "/refresh", + response_model=AccessTokenResponse, + summary="토큰 갱신", + description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.", +) +async def refresh_token( + body: RefreshTokenRequest, + session: AsyncSession = Depends(get_session), +) -> AccessTokenResponse: + """ + 액세스 토큰 갱신 + + 유효한 리프레시 토큰을 제출하면 새 액세스 토큰을 발급합니다. + 리프레시 토큰은 변경되지 않습니다. + """ + return await auth_service.refresh_tokens( + refresh_token=body.refresh_token, + session=session, + ) + + +@router.post( + "/logout", + status_code=status.HTTP_204_NO_CONTENT, + summary="로그아웃", + description="현재 세션의 리프레시 토큰을 폐기합니다.", +) +async def logout( + body: RefreshTokenRequest, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> Response: + """ + 로그아웃 + + 현재 사용 중인 리프레시 토큰을 폐기합니다. + 해당 토큰으로는 더 이상 액세스 토큰을 갱신할 수 없습니다. + """ + await auth_service.logout( + user_id=current_user.id, + refresh_token=body.refresh_token, + session=session, + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post( + "/logout/all", + status_code=status.HTTP_204_NO_CONTENT, + summary="모든 기기에서 로그아웃", + description="사용자의 모든 리프레시 토큰을 폐기합니다.", +) +async def logout_all( + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> Response: + """ + 모든 기기에서 로그아웃 + + 사용자의 모든 리프레시 토큰을 폐기합니다. + 모든 기기에서 재로그인이 필요합니다. + """ + await auth_service.logout_all( + user_id=current_user.id, + session=session, + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get( + "/me", + response_model=UserResponse, + summary="내 정보 조회", + description="현재 로그인한 사용자의 정보를 반환합니다.", +) +async def get_me( + current_user: User = Depends(get_current_user), +) -> UserResponse: + """ + 내 정보 조회 + + 현재 로그인한 사용자의 상세 정보를 반환합니다. + """ + return UserResponse.model_validate(current_user) diff --git a/app/user/api/user_admin.py b/app/user/api/user_admin.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/dependencies/__init__.py b/app/user/dependencies/__init__.py new file mode 100644 index 0000000..b4266f5 --- /dev/null +++ b/app/user/dependencies/__init__.py @@ -0,0 +1,15 @@ +""" +User 의존성 주입 모듈 +""" + +from app.user.dependencies.auth import ( + get_current_user, + get_current_user_optional, + get_current_admin, +) + +__all__ = [ + "get_current_user", + "get_current_user_optional", + "get_current_admin", +] diff --git a/app/user/dependencies/auth.py b/app/user/dependencies/auth.py new file mode 100644 index 0000000..441762b --- /dev/null +++ b/app/user/dependencies/auth.py @@ -0,0 +1,145 @@ +""" +인증 의존성 주입 + +FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다. +""" + +from typing import Optional + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session +from app.user.exceptions import ( + AdminRequiredError, + InvalidTokenError, + MissingTokenError, + TokenExpiredError, + UserInactiveError, + UserNotFoundError, +) +from app.user.models import User +from app.user.services.jwt import decode_token + +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + session: AsyncSession = Depends(get_session), +) -> User: + """ + 현재 로그인한 사용자 반환 (필수 인증) + + Args: + credentials: HTTP Bearer 토큰 + session: DB 세션 + + Returns: + User: 현재 로그인한 사용자 + + Raises: + MissingTokenError: 토큰이 없는 경우 + InvalidTokenError: 토큰이 유효하지 않은 경우 + TokenExpiredError: 토큰이 만료된 경우 + UserNotFoundError: 사용자를 찾을 수 없는 경우 + UserInactiveError: 비활성화된 계정인 경우 + """ + if credentials is None: + raise MissingTokenError() + + payload = decode_token(credentials.credentials) + if payload is None: + raise InvalidTokenError() + + # 토큰 타입 확인 + if payload.get("type") != "access": + raise InvalidTokenError("액세스 토큰이 아닙니다.") + + user_id = payload.get("sub") + if user_id is None: + raise InvalidTokenError() + + # 사용자 조회 + result = await session.execute( + select(User).where( + User.id == int(user_id), + User.is_deleted == False, # noqa: E712 + ) + ) + user = result.scalar_one_or_none() + + if user is None: + raise UserNotFoundError() + + if not user.is_active: + raise UserInactiveError() + + return user + + +async def get_current_user_optional( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + session: AsyncSession = Depends(get_session), +) -> Optional[User]: + """ + 현재 로그인한 사용자 반환 (선택적 인증) + + 토큰이 없거나 유효하지 않으면 None 반환 + + Args: + credentials: HTTP Bearer 토큰 + session: DB 세션 + + Returns: + User | None: 로그인한 사용자 또는 None + """ + if credentials is None: + return None + + payload = decode_token(credentials.credentials) + if payload is None: + return None + + if payload.get("type") != "access": + return None + + user_id = payload.get("sub") + if user_id is None: + return None + + result = await session.execute( + select(User).where( + User.id == int(user_id), + User.is_deleted == False, # noqa: E712 + ) + ) + user = result.scalar_one_or_none() + + if user is None or not user.is_active: + return None + + return user + + +async def get_current_admin( + current_user: User = Depends(get_current_user), +) -> User: + """ + 현재 로그인한 관리자 반환 + + Args: + current_user: 현재 로그인한 사용자 + + Returns: + User: 관리자 권한이 있는 사용자 + + Raises: + AdminRequiredError: 관리자 권한이 없는 경우 + """ + if not current_user.is_admin and current_user.role != "admin": + raise AdminRequiredError() + + return current_user diff --git a/app/user/dependency.py b/app/user/dependency.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/exceptions.py b/app/user/exceptions.py new file mode 100644 index 0000000..41752ae --- /dev/null +++ b/app/user/exceptions.py @@ -0,0 +1,141 @@ +""" +User 모듈 커스텀 예외 정의 + +인증 및 사용자 관련 에러를 처리하기 위한 예외 클래스들입니다. +""" + +from fastapi import HTTPException, status + + +class AuthException(HTTPException): + """인증 관련 기본 예외""" + + def __init__( + self, + status_code: int, + code: str, + message: str, + ): + super().__init__( + status_code=status_code, + detail={"code": code, "message": message}, + ) + + +# ============================================================================= +# 카카오 OAuth 관련 예외 +# ============================================================================= +class InvalidAuthCodeError(AuthException): + """유효하지 않은 인가 코드""" + + def __init__(self, message: str = "유효하지 않은 인가 코드입니다."): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + code="INVALID_CODE", + message=message, + ) + + +class KakaoAuthFailedError(AuthException): + """카카오 인증 실패""" + + def __init__(self, message: str = "카카오 인증에 실패했습니다."): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + code="KAKAO_AUTH_FAILED", + message=message, + ) + + +class KakaoAPIError(AuthException): + """카카오 API 호출 오류""" + + def __init__(self, message: str = "카카오 API 호출 중 오류가 발생했습니다."): + super().__init__( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + code="KAKAO_API_ERROR", + message=message, + ) + + +# ============================================================================= +# JWT 토큰 관련 예외 +# ============================================================================= +class TokenExpiredError(AuthException): + """토큰 만료""" + + def __init__(self, message: str = "토큰이 만료되었습니다. 다시 로그인해주세요."): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + code="TOKEN_EXPIRED", + message=message, + ) + + +class InvalidTokenError(AuthException): + """유효하지 않은 토큰""" + + def __init__(self, message: str = "유효하지 않은 토큰입니다."): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + code="INVALID_TOKEN", + message=message, + ) + + +class TokenRevokedError(AuthException): + """취소된 토큰""" + + def __init__(self, message: str = "취소된 토큰입니다. 다시 로그인해주세요."): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + code="TOKEN_REVOKED", + message=message, + ) + + +class MissingTokenError(AuthException): + """토큰 누락""" + + def __init__(self, message: str = "인증 토큰이 필요합니다."): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + code="MISSING_TOKEN", + message=message, + ) + + +# ============================================================================= +# 사용자 관련 예외 +# ============================================================================= +class UserNotFoundError(AuthException): + """사용자 없음""" + + def __init__(self, message: str = "사용자를 찾을 수 없습니다."): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + code="USER_NOT_FOUND", + message=message, + ) + + +class UserInactiveError(AuthException): + """비활성화된 계정""" + + def __init__(self, message: str = "비활성화된 계정입니다. 관리자에게 문의하세요."): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + code="USER_INACTIVE", + message=message, + ) + + +class AdminRequiredError(AuthException): + """관리자 권한 필요""" + + def __init__(self, message: str = "관리자 권한이 필요합니다."): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + code="ADMIN_REQUIRED", + message=message, + ) diff --git a/app/user/models.py b/app/user/models.py new file mode 100644 index 0000000..88de7cd --- /dev/null +++ b/app/user/models.py @@ -0,0 +1,375 @@ +""" +User 모듈 SQLAlchemy 모델 정의 + +카카오 소셜 로그인 기반 사용자 관리 모델입니다. +""" + +from datetime import date, datetime +from typing import TYPE_CHECKING, List, Optional + +from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + +if TYPE_CHECKING: + from app.home.models import UserProject + + +class User(Base): + """ + 사용자 테이블 (카카오 소셜 로그인) + + 카카오 로그인을 통해 인증된 사용자 정보를 저장합니다. + + Attributes: + id: 고유 식별자 (자동 증가) + kakao_id: 카카오 고유 ID (필수, 유니크) + email: 이메일 주소 (선택, 카카오에서 제공 시) + nickname: 카카오 닉네임 (선택) + profile_image_url: 카카오 프로필 이미지 URL (선택) + thumbnail_image_url: 카카오 썸네일 이미지 URL (선택) + phone: 전화번호 (선택) + name: 실명 (선택) + birth_date: 생년월일 (선택) + gender: 성별 (선택) + is_active: 계정 활성화 상태 (기본 True) + is_admin: 관리자 여부 (기본 False) + role: 사용자 권한 (user, manager, admin 등) + is_deleted: 소프트 삭제 여부 (기본 False) + deleted_at: 삭제 일시 + last_login_at: 마지막 로그인 일시 + created_at: 계정 생성 일시 + updated_at: 계정 정보 수정 일시 + + 권한 체계: + - user: 일반 사용자 (기본값) + - manager: 매니저 (일부 관리 권한) + - admin: 관리자 (is_admin=True와 동일) + + 소프트 삭제: + - is_deleted=True로 설정 시 삭제된 것으로 처리 + - 실제 데이터는 DB에 유지됨 + + Relationships: + user_projects: Project와의 M:N 관계 (중계 테이블 통한 연결) + projects: 사용자가 참여한 프로젝트 목록 (Association Proxy) + + 카카오 API 응답 필드 매핑: + - kakao_id: id (카카오 회원번호) + - email: kakao_account.email + - nickname: kakao_account.profile.nickname 또는 properties.nickname + - profile_image_url: kakao_account.profile.profile_image_url + - thumbnail_image_url: kakao_account.profile.thumbnail_image_url + - birth_date: kakao_account.birthday 또는 kakao_account.birthyear + - gender: kakao_account.gender (male/female) + """ + + __tablename__ = "user" + __table_args__ = ( + Index("idx_user_kakao_id", "kakao_id", unique=True), + Index("idx_user_email", "email"), + Index("idx_user_phone", "phone"), + Index("idx_user_is_active", "is_active"), + Index("idx_user_is_deleted", "is_deleted"), + Index("idx_user_role", "role"), + Index("idx_user_created_at", "created_at"), + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + # ========================================================================== + # 기본 식별자 + # ========================================================================== + id: Mapped[int] = mapped_column( + BigInteger, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + # ========================================================================== + # 카카오 소셜 로그인 필수 정보 + # ========================================================================== + kakao_id: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + unique=True, + comment="카카오 고유 ID (회원번호)", + ) + + # ========================================================================== + # 카카오에서 제공하는 사용자 정보 (선택적) + # ========================================================================== + email: Mapped[Optional[str]] = mapped_column( + String(255), + nullable=True, + comment="이메일 주소 (카카오 계정 이메일, 동의 시 제공)", + ) + + nickname: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + comment="카카오 닉네임", + ) + + profile_image_url: Mapped[Optional[str]] = mapped_column( + String(2048), + nullable=True, + comment="카카오 프로필 이미지 URL", + ) + + thumbnail_image_url: Mapped[Optional[str]] = mapped_column( + String(2048), + nullable=True, + comment="카카오 썸네일 이미지 URL", + ) + + # ========================================================================== + # 추가 사용자 정보 + # ========================================================================== + phone: Mapped[Optional[str]] = mapped_column( + String(20), + nullable=True, + comment="전화번호 (본인인증, 알림용)", + ) + + name: Mapped[Optional[str]] = mapped_column( + String(50), + nullable=True, + comment="실명 (법적 실명, 결제/계약 시 사용)", + ) + + birth_date: Mapped[Optional[date]] = mapped_column( + Date, + nullable=True, + comment="생년월일 (카카오 제공 또는 직접 입력)", + ) + + gender: Mapped[Optional[str]] = mapped_column( + String(10), + nullable=True, + comment="성별 (male: 남성, female: 여성)", + ) + + # ========================================================================== + # 계정 상태 관리 + # ========================================================================== + is_active: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=True, + comment="계정 활성화 상태 (비활성화 시 로그인 차단)", + ) + + is_admin: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + comment="관리자 권한 여부", + ) + + role: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="user", + comment="사용자 권한 (user: 일반, manager: 매니저, admin: 관리자)", + ) + + # ========================================================================== + # 소프트 삭제 + # ========================================================================== + is_deleted: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + comment="소프트 삭제 여부 (True: 삭제됨)", + ) + + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, + nullable=True, + comment="삭제 일시", + ) + + # ========================================================================== + # 시간 정보 + # ========================================================================== + last_login_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, + nullable=True, + comment="마지막 로그인 일시", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="계정 생성 일시", + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), + comment="계정 정보 수정 일시", + ) + + # ========================================================================== + # Project M:N 관계 (중계 테이블 UserProject 통한 연결) + # ========================================================================== + # back_populates: UserProject.user와 양방향 연결 + # cascade: User 삭제 시 UserProject 레코드도 삭제 (Project는 유지) + # lazy="selectin": N+1 문제 방지 + # ========================================================================== + user_projects: Mapped[List["UserProject"]] = relationship( + "UserProject", + back_populates="user", + cascade="all, delete-orphan", + lazy="selectin", + ) + + # ========================================================================== + # RefreshToken 1:N 관계 + # ========================================================================== + # 한 사용자는 여러 리프레시 토큰을 가질 수 있음 (다중 기기 로그인) + # ========================================================================== + refresh_tokens: Mapped[List["RefreshToken"]] = relationship( + "RefreshToken", + back_populates="user", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + +class RefreshToken(Base): + """ + 리프레시 토큰 테이블 + + JWT 리프레시 토큰을 DB에 저장하여 관리합니다. + 토큰 폐기(로그아웃), 다중 기기 로그인 관리 등에 활용됩니다. + + Attributes: + id: 고유 식별자 (자동 증가) + user_id: 사용자 외래키 (User.id 참조) + token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X) + expires_at: 토큰 만료 일시 + is_revoked: 토큰 폐기 여부 (로그아웃 시 True) + created_at: 토큰 생성 일시 + revoked_at: 토큰 폐기 일시 + user_agent: 접속 기기 정보 (브라우저, OS 등) + ip_address: 접속 IP 주소 + + 보안 고려사항: + - 토큰 원본은 저장하지 않고 해시값만 저장 + - 토큰 검증 시 해시값 비교로 유효성 확인 + - 로그아웃 시 is_revoked=True로 설정하여 재사용 방지 + """ + + __tablename__ = "refresh_token" + __table_args__ = ( + Index("idx_refresh_token_user_id", "user_id"), + Index("idx_refresh_token_token_hash", "token_hash", unique=True), + Index("idx_refresh_token_expires_at", "expires_at"), + Index("idx_refresh_token_is_revoked", "is_revoked"), + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + user_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + comment="사용자 외래키 (User.id 참조)", + ) + + token_hash: Mapped[str] = mapped_column( + String(64), + nullable=False, + unique=True, + comment="리프레시 토큰 SHA-256 해시값", + ) + + expires_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + comment="토큰 만료 일시", + ) + + is_revoked: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + comment="토큰 폐기 여부 (True: 폐기됨)", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="토큰 생성 일시", + ) + + revoked_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, + nullable=True, + comment="토큰 폐기 일시", + ) + + user_agent: Mapped[Optional[str]] = mapped_column( + String(500), + nullable=True, + comment="접속 기기 정보 (브라우저, OS 등)", + ) + + ip_address: Mapped[Optional[str]] = mapped_column( + String(45), + nullable=True, + comment="접속 IP 주소 (IPv4/IPv6)", + ) + + # ========================================================================== + # User 관계 + # ========================================================================== + user: Mapped["User"] = relationship( + "User", + back_populates="refresh_tokens", + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/app/user/schemas/__init__.py b/app/user/schemas/__init__.py new file mode 100644 index 0000000..25dc83a --- /dev/null +++ b/app/user/schemas/__init__.py @@ -0,0 +1,25 @@ +from app.user.schemas.user_schema import ( + AccessTokenResponse, + KakaoCallbackRequest, + KakaoLoginResponse, + KakaoTokenResponse, + KakaoUserInfo, + LoginResponse, + RefreshTokenRequest, + TokenResponse, + UserBriefResponse, + UserResponse, +) + +__all__ = [ + "AccessTokenResponse", + "KakaoCallbackRequest", + "KakaoLoginResponse", + "KakaoTokenResponse", + "KakaoUserInfo", + "LoginResponse", + "RefreshTokenRequest", + "TokenResponse", + "UserBriefResponse", + "UserResponse", +] diff --git a/app/user/schemas/user_schema.py b/app/user/schemas/user_schema.py new file mode 100644 index 0000000..f1b8c32 --- /dev/null +++ b/app/user/schemas/user_schema.py @@ -0,0 +1,220 @@ +""" +User 모듈 Pydantic 스키마 정의 + +API 요청/응답 검증을 위한 스키마들입니다. +""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +# ============================================================================= +# 카카오 OAuth 스키마 +# ============================================================================= +class KakaoLoginResponse(BaseModel): + """카카오 로그인 URL 응답""" + + auth_url: str = Field(..., description="카카오 인증 페이지 URL") + + model_config = { + "json_schema_extra": { + "example": { + "auth_url": "https://kauth.kakao.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8000/api/v1/user/auth/kakao/callback&response_type=code" + } + } + } + + +class KakaoCallbackRequest(BaseModel): + """카카오 콜백 요청 (인가 코드)""" + + code: str = Field(..., min_length=1, description="카카오 인가 코드") + + model_config = { + "json_schema_extra": { + "example": { + "code": "AUTHORIZATION_CODE_FROM_KAKAO" + } + } + } + + +# ============================================================================= +# JWT 토큰 스키마 +# ============================================================================= +class TokenResponse(BaseModel): + """토큰 발급 응답""" + + access_token: str = Field(..., description="액세스 토큰") + refresh_token: str = Field(..., description="리프레시 토큰") + token_type: str = Field(default="Bearer", description="토큰 타입") + expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)") + + model_config = { + "json_schema_extra": { + "example": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.xxx", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy", + "token_type": "Bearer", + "expires_in": 3600 + } + } + } + + +class AccessTokenResponse(BaseModel): + """액세스 토큰 갱신 응답""" + + access_token: str = Field(..., description="액세스 토큰") + token_type: str = Field(default="Bearer", description="토큰 타입") + expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)") + + model_config = { + "json_schema_extra": { + "example": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.new_token", + "token_type": "Bearer", + "expires_in": 3600 + } + } + } + + +class RefreshTokenRequest(BaseModel): + """토큰 갱신 요청""" + + refresh_token: str = Field(..., min_length=1, description="리프레시 토큰") + + model_config = { + "json_schema_extra": { + "example": { + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy" + } + } + } + + +# ============================================================================= +# 사용자 정보 스키마 +# ============================================================================= +class UserResponse(BaseModel): + """사용자 정보 응답""" + + id: int = Field(..., description="사용자 ID") + kakao_id: int = Field(..., description="카카오 회원번호") + email: Optional[str] = Field(None, description="이메일") + nickname: Optional[str] = Field(None, description="닉네임") + profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL") + thumbnail_image_url: Optional[str] = Field(None, description="썸네일 이미지 URL") + is_active: bool = Field(..., description="계정 활성화 상태") + is_admin: bool = Field(..., description="관리자 여부") + last_login_at: Optional[datetime] = Field(None, description="마지막 로그인 일시") + created_at: datetime = Field(..., description="가입 일시") + + model_config = { + "from_attributes": True, + "json_schema_extra": { + "example": { + "id": 1, + "kakao_id": 1234567890, + "email": "user@kakao.com", + "nickname": "홍길동", + "profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg", + "thumbnail_image_url": "https://k.kakaocdn.net/dn/.../thumb.jpg", + "is_active": True, + "is_admin": False, + "last_login_at": "2026-01-15T10:30:00", + "created_at": "2026-01-01T09:00:00" + } + } + } + + +class UserBriefResponse(BaseModel): + """사용자 간략 정보 (토큰 응답에 포함)""" + + id: int = Field(..., description="사용자 ID") + nickname: Optional[str] = Field(None, description="닉네임") + email: Optional[str] = Field(None, description="이메일") + profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL") + is_new_user: bool = Field(..., description="신규 가입 여부") + + model_config = { + "from_attributes": True, + "json_schema_extra": { + "example": { + "id": 1, + "nickname": "홍길동", + "email": "user@kakao.com", + "profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg", + "is_new_user": False + } + } + } + + +class LoginResponse(BaseModel): + """로그인 응답 (토큰 + 사용자 정보)""" + + access_token: str = Field(..., description="액세스 토큰") + refresh_token: str = Field(..., description="리프레시 토큰") + token_type: str = Field(default="Bearer", description="토큰 타입") + expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)") + user: UserBriefResponse = Field(..., description="사용자 정보") + + model_config = { + "json_schema_extra": { + "example": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.xxx", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy", + "token_type": "Bearer", + "expires_in": 3600, + "user": { + "id": 1, + "nickname": "홍길동", + "email": "user@kakao.com", + "profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg", + "is_new_user": False + } + } + } + } + + +# ============================================================================= +# 내부 사용 스키마 (카카오 API 응답 파싱) +# ============================================================================= +class KakaoTokenResponse(BaseModel): + """카카오 토큰 응답 (내부 사용)""" + + access_token: str + token_type: str + refresh_token: Optional[str] = None + expires_in: int + scope: Optional[str] = None + refresh_token_expires_in: Optional[int] = None + + +class KakaoProfile(BaseModel): + """카카오 프로필 정보 (내부 사용)""" + + nickname: Optional[str] = None + profile_image_url: Optional[str] = None + thumbnail_image_url: Optional[str] = None + is_default_image: Optional[bool] = None + + +class KakaoAccount(BaseModel): + """카카오 계정 정보 (내부 사용)""" + + email: Optional[str] = None + profile: Optional[KakaoProfile] = None + + +class KakaoUserInfo(BaseModel): + """카카오 사용자 정보 (내부 사용)""" + + id: int + kakao_account: Optional[KakaoAccount] = None diff --git a/app/user/services/__init__.py b/app/user/services/__init__.py new file mode 100644 index 0000000..e8d19a5 --- /dev/null +++ b/app/user/services/__init__.py @@ -0,0 +1,29 @@ +""" +User 서비스 모듈 + +인증 및 사용자 관련 비즈니스 로직을 제공합니다. +""" + +from app.user.services.auth import AuthService, auth_service +from app.user.services.jwt import ( + create_access_token, + create_refresh_token, + decode_token, + get_access_token_expire_seconds, + get_refresh_token_expires_at, + get_token_hash, +) +from app.user.services.kakao import KakaoOAuthClient, kakao_client + +__all__ = [ + "AuthService", + "auth_service", + "KakaoOAuthClient", + "kakao_client", + "create_access_token", + "create_refresh_token", + "decode_token", + "get_token_hash", + "get_refresh_token_expires_at", + "get_access_token_expire_seconds", +] diff --git a/app/user/services/auth.py b/app/user/services/auth.py new file mode 100644 index 0000000..df83706 --- /dev/null +++ b/app/user/services/auth.py @@ -0,0 +1,377 @@ +""" +인증 서비스 + +카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다. +""" + +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.user.exceptions import ( + InvalidTokenError, + TokenExpiredError, + TokenRevokedError, + UserInactiveError, + UserNotFoundError, +) +from app.user.models import RefreshToken, User +from app.user.schemas.user_schema import ( + AccessTokenResponse, + KakaoUserInfo, + LoginResponse, + UserBriefResponse, +) +from app.user.services.jwt import ( + create_access_token, + create_refresh_token, + decode_token, + get_access_token_expire_seconds, + get_refresh_token_expires_at, + get_token_hash, +) +from app.user.services.kakao import kakao_client + + +class AuthService: + """ + 인증 서비스 + + 카카오 로그인, JWT 토큰 발급/갱신/폐기, 사용자 관리 기능을 제공합니다. + """ + + async def kakao_login( + self, + code: str, + session: AsyncSession, + user_agent: Optional[str] = None, + ip_address: Optional[str] = None, + ) -> LoginResponse: + """ + 카카오 로그인 처리 + + 1. 카카오 인가 코드로 액세스 토큰 획득 + 2. 카카오 사용자 정보 조회 + 3. 사용자 조회 또는 생성 + 4. JWT 토큰 발급 및 리프레시 토큰 저장 + + Args: + code: 카카오 인가 코드 + session: DB 세션 + user_agent: 클라이언트 User-Agent + ip_address: 클라이언트 IP 주소 + + Returns: + LoginResponse: 토큰 및 사용자 정보 + """ + # 1. 카카오 토큰 획득 + kakao_token = await kakao_client.get_access_token(code) + + # 2. 카카오 사용자 정보 조회 + kakao_user_info = await kakao_client.get_user_info(kakao_token.access_token) + + # 3. 사용자 조회 또는 생성 + user, is_new_user = await self._get_or_create_user(kakao_user_info, session) + + # 4. 비활성화 계정 체크 + if not user.is_active: + raise UserInactiveError() + + # 5. JWT 토큰 생성 + access_token = create_access_token(user.id) + refresh_token = create_refresh_token(user.id) + + # 6. 리프레시 토큰 DB 저장 + await self._save_refresh_token( + user_id=user.id, + token=refresh_token, + session=session, + user_agent=user_agent, + ip_address=ip_address, + ) + + # 7. 마지막 로그인 시간 업데이트 + user.last_login_at = datetime.now(timezone.utc) + await session.commit() + + return LoginResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="Bearer", + expires_in=get_access_token_expire_seconds(), + user=UserBriefResponse( + id=user.id, + nickname=user.nickname, + email=user.email, + profile_image_url=user.profile_image_url, + is_new_user=is_new_user, + ), + ) + + async def refresh_tokens( + self, + refresh_token: str, + session: AsyncSession, + ) -> AccessTokenResponse: + """ + 리프레시 토큰으로 액세스 토큰 갱신 + + Args: + refresh_token: 리프레시 토큰 + session: DB 세션 + + Returns: + AccessTokenResponse: 새 액세스 토큰 + + Raises: + InvalidTokenError: 토큰이 유효하지 않은 경우 + TokenExpiredError: 토큰이 만료된 경우 + TokenRevokedError: 토큰이 폐기된 경우 + """ + # 1. 토큰 디코딩 및 검증 + payload = decode_token(refresh_token) + if payload is None: + raise InvalidTokenError() + + if payload.get("type") != "refresh": + raise InvalidTokenError("리프레시 토큰이 아닙니다.") + + # 2. DB에서 리프레시 토큰 조회 + token_hash = get_token_hash(refresh_token) + db_token = await self._get_refresh_token_by_hash(token_hash, session) + + if db_token is None: + raise InvalidTokenError() + + # 3. 토큰 상태 확인 + if db_token.is_revoked: + raise TokenRevokedError() + + if db_token.expires_at < datetime.now(timezone.utc): + raise TokenExpiredError() + + # 4. 사용자 확인 + user_id = int(payload.get("sub")) + user = await self._get_user_by_id(user_id, session) + + if user is None: + raise UserNotFoundError() + + if not user.is_active: + raise UserInactiveError() + + # 5. 새 액세스 토큰 발급 + new_access_token = create_access_token(user.id) + + return AccessTokenResponse( + access_token=new_access_token, + token_type="Bearer", + expires_in=get_access_token_expire_seconds(), + ) + + async def logout( + self, + user_id: int, + refresh_token: str, + session: AsyncSession, + ) -> None: + """ + 로그아웃 (리프레시 토큰 폐기) + + Args: + user_id: 사용자 ID + refresh_token: 폐기할 리프레시 토큰 + session: DB 세션 + """ + token_hash = get_token_hash(refresh_token) + await self._revoke_refresh_token_by_hash(token_hash, session) + + async def logout_all( + self, + user_id: int, + session: AsyncSession, + ) -> None: + """ + 모든 기기에서 로그아웃 (사용자의 모든 리프레시 토큰 폐기) + + Args: + user_id: 사용자 ID + session: DB 세션 + """ + await self._revoke_all_user_tokens(user_id, session) + + async def _get_or_create_user( + self, + kakao_user_info: KakaoUserInfo, + session: AsyncSession, + ) -> tuple[User, bool]: + """ + 사용자 조회 또는 생성 + + Args: + kakao_user_info: 카카오 사용자 정보 + session: DB 세션 + + Returns: + (User, is_new_user) 튜플 + """ + kakao_id = kakao_user_info.id + kakao_account = kakao_user_info.kakao_account + profile = kakao_account.profile if kakao_account else None + + # 기존 사용자 조회 + result = await session.execute( + select(User).where(User.kakao_id == kakao_id) + ) + user = result.scalar_one_or_none() + + if user is not None: + # 기존 사용자: 프로필 정보 업데이트 + if profile: + user.nickname = profile.nickname + user.profile_image_url = profile.profile_image_url + user.thumbnail_image_url = profile.thumbnail_image_url + if kakao_account and kakao_account.email: + user.email = kakao_account.email + await session.flush() + return user, False + + # 신규 사용자 생성 + new_user = User( + kakao_id=kakao_id, + email=kakao_account.email if kakao_account else None, + nickname=profile.nickname if profile else None, + profile_image_url=profile.profile_image_url if profile else None, + thumbnail_image_url=profile.thumbnail_image_url if profile else None, + ) + session.add(new_user) + await session.flush() + await session.refresh(new_user) + return new_user, True + + async def _save_refresh_token( + self, + user_id: int, + token: str, + session: AsyncSession, + user_agent: Optional[str] = None, + ip_address: Optional[str] = None, + ) -> RefreshToken: + """ + 리프레시 토큰 DB 저장 + + Args: + user_id: 사용자 ID + token: 리프레시 토큰 + session: DB 세션 + user_agent: User-Agent + ip_address: IP 주소 + + Returns: + 저장된 RefreshToken 객체 + """ + token_hash = get_token_hash(token) + expires_at = get_refresh_token_expires_at() + + refresh_token = RefreshToken( + user_id=user_id, + token_hash=token_hash, + expires_at=expires_at, + user_agent=user_agent, + ip_address=ip_address, + ) + session.add(refresh_token) + await session.flush() + return refresh_token + + async def _get_refresh_token_by_hash( + self, + token_hash: str, + session: AsyncSession, + ) -> Optional[RefreshToken]: + """ + 해시값으로 리프레시 토큰 조회 + + Args: + token_hash: 토큰 해시값 + session: DB 세션 + + Returns: + RefreshToken 객체 또는 None + """ + result = await session.execute( + select(RefreshToken).where(RefreshToken.token_hash == token_hash) + ) + return result.scalar_one_or_none() + + async def _get_user_by_id( + self, + user_id: int, + session: AsyncSession, + ) -> Optional[User]: + """ + ID로 사용자 조회 + + Args: + user_id: 사용자 ID + session: DB 세션 + + Returns: + User 객체 또는 None + """ + result = await session.execute( + select(User).where(User.id == user_id, User.is_deleted == False) # noqa: E712 + ) + return result.scalar_one_or_none() + + async def _revoke_refresh_token_by_hash( + self, + token_hash: str, + session: AsyncSession, + ) -> None: + """ + 해시값으로 리프레시 토큰 폐기 + + Args: + token_hash: 토큰 해시값 + session: DB 세션 + """ + await session.execute( + update(RefreshToken) + .where(RefreshToken.token_hash == token_hash) + .values( + is_revoked=True, + revoked_at=datetime.now(timezone.utc), + ) + ) + await session.commit() + + async def _revoke_all_user_tokens( + self, + user_id: int, + session: AsyncSession, + ) -> None: + """ + 사용자의 모든 리프레시 토큰 폐기 + + Args: + user_id: 사용자 ID + session: DB 세션 + """ + await session.execute( + update(RefreshToken) + .where( + RefreshToken.user_id == user_id, + RefreshToken.is_revoked == False, # noqa: E712 + ) + .values( + is_revoked=True, + revoked_at=datetime.now(timezone.utc), + ) + ) + await session.commit() + + +auth_service = AuthService() diff --git a/app/user/services/jwt.py b/app/user/services/jwt.py new file mode 100644 index 0000000..91c2eee --- /dev/null +++ b/app/user/services/jwt.py @@ -0,0 +1,121 @@ +""" +JWT 토큰 유틸리티 + +Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니다. +""" + +import hashlib +from datetime import datetime, timedelta, timezone +from typing import Optional + +from jose import JWTError, jwt + +from config import jwt_settings + + +def create_access_token(user_id: int) -> str: + """ + JWT 액세스 토큰 생성 + + Args: + user_id: 사용자 ID + + Returns: + JWT 액세스 토큰 문자열 + """ + expire = datetime.now(timezone.utc) + timedelta( + minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode = { + "sub": str(user_id), + "exp": expire, + "type": "access", + } + return jwt.encode( + to_encode, + jwt_settings.JWT_SECRET, + algorithm=jwt_settings.JWT_ALGORITHM, + ) + + +def create_refresh_token(user_id: int) -> str: + """ + JWT 리프레시 토큰 생성 + + Args: + user_id: 사용자 ID + + Returns: + JWT 리프레시 토큰 문자열 + """ + expire = datetime.now(timezone.utc) + timedelta( + days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS + ) + to_encode = { + "sub": str(user_id), + "exp": expire, + "type": "refresh", + } + return jwt.encode( + to_encode, + jwt_settings.JWT_SECRET, + algorithm=jwt_settings.JWT_ALGORITHM, + ) + + +def decode_token(token: str) -> Optional[dict]: + """ + JWT 토큰 디코딩 + + Args: + token: JWT 토큰 문자열 + + Returns: + 디코딩된 페이로드 딕셔너리, 실패 시 None + """ + try: + payload = jwt.decode( + token, + jwt_settings.JWT_SECRET, + algorithms=[jwt_settings.JWT_ALGORITHM], + ) + return payload + except JWTError: + return None + + +def get_token_hash(token: str) -> str: + """ + 토큰의 SHA-256 해시값 생성 + + 리프레시 토큰을 DB에 저장할 때 원본 대신 해시값을 저장합니다. + + Args: + token: 해시할 토큰 문자열 + + Returns: + 토큰의 SHA-256 해시값 (64자 hex 문자열) + """ + return hashlib.sha256(token.encode()).hexdigest() + + +def get_refresh_token_expires_at() -> datetime: + """ + 리프레시 토큰 만료 시간 계산 + + Returns: + 리프레시 토큰 만료 datetime (UTC) + """ + return datetime.now(timezone.utc) + timedelta( + days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS + ) + + +def get_access_token_expire_seconds() -> int: + """ + 액세스 토큰 만료 시간(초) 반환 + + Returns: + 액세스 토큰 만료 시간 (초) + """ + return jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60 diff --git a/app/user/services/kakao.py b/app/user/services/kakao.py new file mode 100644 index 0000000..eee3a6c --- /dev/null +++ b/app/user/services/kakao.py @@ -0,0 +1,123 @@ +""" +카카오 OAuth API 클라이언트 + +카카오 로그인 인증 흐름을 처리하는 클라이언트입니다. +""" + +import aiohttp + +from config import kakao_settings + +from app.user.exceptions import KakaoAPIError, KakaoAuthFailedError +from app.user.schemas.user_schema import KakaoTokenResponse, KakaoUserInfo + + +class KakaoOAuthClient: + """ + 카카오 OAuth API 클라이언트 + + 카카오 로그인 인증 흐름: + 1. get_authorization_url()로 카카오 로그인 페이지 URL 획득 + 2. 사용자가 카카오에서 로그인 후 인가 코드(code) 발급 + 3. get_access_token()으로 인가 코드를 액세스 토큰으로 교환 + 4. get_user_info()로 사용자 정보 조회 + """ + + AUTH_URL = "https://kauth.kakao.com/oauth/authorize" + TOKEN_URL = "https://kauth.kakao.com/oauth/token" + USER_INFO_URL = "https://kapi.kakao.com/v2/user/me" + + def __init__(self) -> None: + self.client_id = kakao_settings.KAKAO_CLIENT_ID + self.client_secret = kakao_settings.KAKAO_CLIENT_SECRET + self.redirect_uri = kakao_settings.KAKAO_REDIRECT_URI + + def get_authorization_url(self) -> str: + """ + 카카오 로그인 페이지 URL 반환 + + Returns: + 카카오 OAuth 인증 페이지 URL + """ + return ( + f"{self.AUTH_URL}" + f"?client_id={self.client_id}" + f"&redirect_uri={self.redirect_uri}" + f"&response_type=code" + ) + + async def get_access_token(self, code: str) -> KakaoTokenResponse: + """ + 인가 코드로 액세스 토큰 획득 + + Args: + code: 카카오 로그인 후 발급받은 인가 코드 + + Returns: + KakaoTokenResponse: 카카오 토큰 정보 + + Raises: + KakaoAuthFailedError: 토큰 발급 실패 시 + KakaoAPIError: API 호출 오류 시 + """ + try: + async with aiohttp.ClientSession() as session: + data = { + "grant_type": "authorization_code", + "client_id": self.client_id, + "redirect_uri": self.redirect_uri, + "code": code, + } + + if self.client_secret: + data["client_secret"] = self.client_secret + + async with session.post(self.TOKEN_URL, data=data) as response: + result = await response.json() + + if "error" in result: + error_desc = result.get( + "error_description", result.get("error", "알 수 없는 오류") + ) + raise KakaoAuthFailedError(f"카카오 토큰 발급 실패: {error_desc}") + + return KakaoTokenResponse(**result) + + except KakaoAuthFailedError: + raise + except Exception as e: + raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}") + + async def get_user_info(self, access_token: str) -> KakaoUserInfo: + """ + 액세스 토큰으로 사용자 정보 조회 + + Args: + access_token: 카카오 액세스 토큰 + + Returns: + KakaoUserInfo: 카카오 사용자 정보 + + Raises: + KakaoAuthFailedError: 사용자 정보 조회 실패 시 + KakaoAPIError: API 호출 오류 시 + """ + try: + async with aiohttp.ClientSession() as session: + headers = {"Authorization": f"Bearer {access_token}"} + + async with session.get(self.USER_INFO_URL, headers=headers) as response: + result = await response.json() + + if "id" not in result: + raise KakaoAuthFailedError("카카오 사용자 정보를 가져올 수 없습니다.") + + return KakaoUserInfo(**result) + + except KakaoAuthFailedError: + raise + except Exception as e: + raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}") + + +kakao_client = KakaoOAuthClient() diff --git a/app/user/tests/__init__.py b/app/user/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/tests/conftest.py b/app/user/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/worker/__init__.py b/app/user/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/worker/user_task.py b/app/user/worker/user_task.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/chatgpt_prompt.py b/app/utils/chatgpt_prompt.py index c37310c..9501ce3 100644 --- a/app/utils/chatgpt_prompt.py +++ b/app/utils/chatgpt_prompt.py @@ -1,14 +1,14 @@ import json -import logging import re from openai import AsyncOpenAI +from app.utils.logger import get_logger from config import apikey_settings from app.utils.prompts.prompts import Prompt # 로거 설정 -logger = logging.getLogger(__name__) +logger = get_logger("chatgpt") # fmt: on @@ -44,5 +44,4 @@ class ChatgptService: # GPT API 호출 response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model) - return response diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index 69ec3b9..35d65fd 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -30,16 +30,16 @@ response = await creatomate.make_creatomate_call(template_id, modifications) """ import copy -import logging import time from typing import Literal import httpx +from app.utils.logger import get_logger from config import apikey_settings, creatomate_settings # 로거 설정 -logger = logging.getLogger(__name__) +logger = get_logger("creatomate") # Orientation 타입 정의 @@ -76,14 +76,14 @@ async def close_shared_client() -> None: if _shared_client is not None and not _shared_client.is_closed: await _shared_client.aclose() _shared_client = None - print("[CreatomateService] Shared HTTP client closed") + logger.info("[CreatomateService] Shared HTTP client closed") def clear_template_cache() -> None: """템플릿 캐시를 전체 삭제합니다.""" global _template_cache _template_cache.clear() - print("[CreatomateService] Template cache cleared") + logger.info("[CreatomateService] Template cache cleared") def _is_cache_valid(cached_at: float) -> bool: @@ -164,7 +164,6 @@ class CreatomateService: httpx.HTTPError: 요청 실패 시 """ logger.info(f"[Creatomate] {method} {url}") - print(f"[Creatomate] {method} {url}") client = await get_shared_client() @@ -180,7 +179,6 @@ class CreatomateService: raise ValueError(f"Unsupported HTTP method: {method}") logger.info(f"[Creatomate] Response - Status: {response.status_code}") - print(f"[Creatomate] Response - Status: {response.status_code}") return response async def get_all_templates_data(self) -> dict: @@ -210,12 +208,12 @@ class CreatomateService: if use_cache and template_id in _template_cache: cached = _template_cache[template_id] if _is_cache_valid(cached["cached_at"]): - print(f"[CreatomateService] Cache HIT - {template_id}") + logger.debug(f"[CreatomateService] Cache HIT - {template_id}") return copy.deepcopy(cached["data"]) else: # 만료된 캐시 삭제 del _template_cache[template_id] - print(f"[CreatomateService] Cache EXPIRED - {template_id}") + logger.debug(f"[CreatomateService] Cache EXPIRED - {template_id}") # API 호출 url = f"{self.BASE_URL}/v1/templates/{template_id}" @@ -228,7 +226,7 @@ class CreatomateService: "data": data, "cached_at": time.time(), } - print(f"[CreatomateService] Cache MISS - {template_id} (cached)") + logger.debug(f"[CreatomateService] Cache MISS - {template_id} (cached)") return copy.deepcopy(data) @@ -444,12 +442,13 @@ class CreatomateService: if animation["transition"]: total_template_duration -= animation["duration"] except Exception as e: - print(f"[calc_scene_duration] Error processing element: {elem}, {e}") + logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}") return total_template_duration def extend_template_duration(self, template: dict, target_duration: float) -> dict: """템플릿의 duration을 target_duration으로 확장합니다.""" + target_duration += 0.1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것 template["duration"] = target_duration total_template_duration = self.calc_scene_duration(template) extend_rate = target_duration / total_template_duration @@ -466,7 +465,7 @@ class CreatomateService: assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 animation["duration"] = animation["duration"] * extend_rate except Exception as e: - print( + logger.error( f"[extend_template_duration] Error processing element: {elem}, {e}" ) diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 0000000..18bbbae --- /dev/null +++ b/app/utils/logger.py @@ -0,0 +1,337 @@ +""" +FastAPI용 로깅 모듈 + +Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템. + +사용 예시: + from app.utils.logger import get_logger + + logger = get_logger("song") + logger.info("노래 생성 완료") + logger.error("오류 발생", exc_info=True) + +로그 레벨: + 1. DEBUG: 디버깅 목적 + 2. INFO: 일반 정보 + 3. WARNING: 경고 정보 (작은 문제) + 4. ERROR: 오류 정보 (큰 문제) + 5. CRITICAL: 아주 심각한 문제 +""" + +import logging +import sys +from datetime import datetime +from functools import lru_cache +from logging.handlers import RotatingFileHandler +from typing import Literal + +from config import log_settings + +# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리) +LOG_DIR = log_settings.get_log_dir() + +# 로그 레벨 타입 +LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + + +class LoggerConfig: + """로거 설정 클래스 (config.py의 LogSettings 참조)""" + + # 출력 대상 설정 (LogSettings에서 가져옴) + CONSOLE_ENABLED: bool = log_settings.LOG_CONSOLE_ENABLED + FILE_ENABLED: bool = log_settings.LOG_FILE_ENABLED + + # 기본 설정 (LogSettings에서 가져옴) + DEFAULT_LEVEL: str = log_settings.LOG_LEVEL + CONSOLE_LEVEL: str = log_settings.LOG_CONSOLE_LEVEL + FILE_LEVEL: str = log_settings.LOG_FILE_LEVEL + MAX_BYTES: int = log_settings.LOG_MAX_SIZE_MB * 1024 * 1024 + BACKUP_COUNT: int = log_settings.LOG_BACKUP_COUNT + ENCODING: str = "utf-8" + + # 포맷 설정 (LogSettings에서 가져옴) + CONSOLE_FORMAT: str = log_settings.LOG_CONSOLE_FORMAT + FILE_FORMAT: str = log_settings.LOG_FILE_FORMAT + DATE_FORMAT: str = log_settings.LOG_DATE_FORMAT + + +def _create_console_handler() -> logging.StreamHandler: + """콘솔 핸들러 생성""" + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(getattr(logging, LoggerConfig.CONSOLE_LEVEL)) + formatter = logging.Formatter( + fmt=LoggerConfig.CONSOLE_FORMAT, + datefmt=LoggerConfig.DATE_FORMAT, + style="{", + ) + handler.setFormatter(formatter) + return handler + + +# ============================================================================= +# 공유 파일 핸들러 (싱글톤) +# 모든 로거가 동일한 파일 핸들러를 공유하여 하나의 로그 파일에 기록 +# ============================================================================= +_shared_file_handler: RotatingFileHandler | None = None +_shared_error_handler: RotatingFileHandler | None = None + + +def _get_shared_file_handler() -> RotatingFileHandler: + """ + 공유 파일 핸들러 반환 (싱글톤) + + 모든 로거가 하나의 기본 로그 파일(app.log)에 기록합니다. + 파일명: logs/{날짜}_app.log + """ + global _shared_file_handler + + if _shared_file_handler is None: + today = datetime.today().strftime("%Y-%m-%d") + log_file = LOG_DIR / f"{today}_app.log" + + _shared_file_handler = RotatingFileHandler( + filename=log_file, + maxBytes=LoggerConfig.MAX_BYTES, + backupCount=LoggerConfig.BACKUP_COUNT, + encoding=LoggerConfig.ENCODING, + ) + _shared_file_handler.setLevel(getattr(logging, LoggerConfig.FILE_LEVEL)) + formatter = logging.Formatter( + fmt=LoggerConfig.FILE_FORMAT, + datefmt=LoggerConfig.DATE_FORMAT, + style="{", + ) + _shared_file_handler.setFormatter(formatter) + + return _shared_file_handler + + +def _get_shared_error_handler() -> RotatingFileHandler: + """ + 공유 에러 파일 핸들러 반환 (싱글톤) + + 모든 로거의 ERROR 이상 로그가 하나의 에러 로그 파일(error.log)에 기록됩니다. + 파일명: logs/{날짜}_error.log + """ + global _shared_error_handler + + if _shared_error_handler is None: + today = datetime.today().strftime("%Y-%m-%d") + log_file = LOG_DIR / f"{today}_error.log" + + _shared_error_handler = RotatingFileHandler( + filename=log_file, + maxBytes=LoggerConfig.MAX_BYTES, + backupCount=LoggerConfig.BACKUP_COUNT, + encoding=LoggerConfig.ENCODING, + ) + _shared_error_handler.setLevel(logging.ERROR) + formatter = logging.Formatter( + fmt=LoggerConfig.FILE_FORMAT, + datefmt=LoggerConfig.DATE_FORMAT, + style="{", + ) + _shared_error_handler.setFormatter(formatter) + + return _shared_error_handler + + +@lru_cache(maxsize=32) +def get_logger(name: str = "app") -> logging.Logger: + """ + 로거 인스턴스 반환 (캐싱 적용) + + Args: + name: 로거 이름 (모듈명 권장: "song", "lyric", "video" 등) + + Returns: + 설정된 로거 인스턴스 + + Example: + logger = get_logger("song") + logger.info("노래 처리 시작") + """ + logger = logging.getLogger(name) + + # 이미 핸들러가 설정된 경우 반환 + if logger.handlers: + return logger + + # 로그 레벨 설정 + logger.setLevel(getattr(logging, LoggerConfig.DEFAULT_LEVEL)) + + # 핸들러 추가 (설정에 따라 선택적으로 추가) + if LoggerConfig.CONSOLE_ENABLED: + logger.addHandler(_create_console_handler()) + + if LoggerConfig.FILE_ENABLED: + logger.addHandler(_get_shared_file_handler()) + logger.addHandler(_get_shared_error_handler()) + + # 상위 로거로 전파 방지 (중복 출력 방지) + logger.propagate = False + + return logger + + +def setup_uvicorn_logging() -> dict: + """ + Uvicorn 서버의 로깅 설정을 반환합니다. + + ============================================================ + 언제 사용하는가? + ============================================================ + Uvicorn 서버를 Python 코드로 직접 실행할 때 사용합니다. + CLI 명령어(uvicorn main:app --reload)로 실행할 때는 적용되지 않습니다. + + ============================================================ + 사용 방법 + ============================================================ + 1. Python 코드에서 uvicorn.run() 호출 시: + + # run.py 또는 main.py 하단 + import uvicorn + from app.utils.logger import setup_uvicorn_logging + + if __name__ == "__main__": + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_config=setup_uvicorn_logging(), # 여기서 적용 + ) + + 2. 실행: + python run.py + 또는 + python main.py + + ============================================================ + 어떤 동작을 하는가? + ============================================================ + Uvicorn의 기본 로깅 형식을 애플리케이션의 LogSettings와 일치시킵니다. + + - formatters: 로그 출력 형식 정의 + - default: 일반 로그용 (서버 시작/종료, 에러 등) + - access: HTTP 요청 로그용 (클라이언트 IP, 요청 경로, 상태 코드) + + - handlers: 로그 출력 대상 설정 + - stdout으로 콘솔에 출력 + + - loggers: Uvicorn 내부 로거 설정 + - uvicorn: 메인 로거 + - uvicorn.error: 에러/시작/종료 로그 + - uvicorn.access: HTTP 요청 로그 + + ============================================================ + 출력 예시 + ============================================================ + 적용 전 (Uvicorn 기본): + INFO: 127.0.0.1:52341 - "GET /docs HTTP/1.1" 200 OK + INFO: Uvicorn running on http://0.0.0.0:8000 + + 적용 후: + [2026-01-14 15:30:00] INFO [uvicorn.access] 127.0.0.1 - "GET /docs HTTP/1.1" 200 + [2026-01-14 15:30:00] INFO [uvicorn:startup:45] Uvicorn running on http://0.0.0.0:8000 + + ============================================================ + 반환값 구조 (Python logging.config.dictConfig 형식) + ============================================================ + { + "version": 1, # dictConfig 버전 (항상 1) + "disable_existing_loggers": False, # 기존 로거 유지 + "formatters": { ... }, # 포맷터 정의 + "handlers": { ... }, # 핸들러 정의 + "loggers": { ... }, # 로거 정의 + } + + Returns: + dict: Uvicorn log_config 파라미터에 전달할 설정 딕셔너리 + """ + return { + # -------------------------------------------------------- + # dictConfig 버전 (필수, 항상 1) + # -------------------------------------------------------- + "version": 1, + + # -------------------------------------------------------- + # 기존 로거 비활성화 여부 + # False: 기존 로거 유지 (권장) + # True: 기존 로거 모두 비활성화 + # -------------------------------------------------------- + "disable_existing_loggers": False, + + # -------------------------------------------------------- + # 포맷터 정의 + # 로그 메시지의 출력 형식을 지정합니다. + # -------------------------------------------------------- + "formatters": { + # 일반 로그용 포맷터 (서버 시작/종료, 에러 등) + "default": { + "format": LoggerConfig.CONSOLE_FORMAT, + "datefmt": LoggerConfig.DATE_FORMAT, + "style": "{", # {변수명} 스타일 사용 + }, + # HTTP 요청 로그용 포맷터 + # 사용 가능한 변수: client_addr, request_line, status_code + "access": { + "format": "[{asctime}] {levelname:8} [{name}] {client_addr} - \"{request_line}\" {status_code}", + "datefmt": LoggerConfig.DATE_FORMAT, + "style": "{", + }, + }, + + # -------------------------------------------------------- + # 핸들러 정의 + # 로그를 어디에 출력할지 지정합니다. + # -------------------------------------------------------- + "handlers": { + # 일반 로그 핸들러 (stdout 출력) + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + # HTTP 요청 로그 핸들러 (stdout 출력) + "access": { + "formatter": "access", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + }, + + # -------------------------------------------------------- + # 로거 정의 + # Uvicorn 내부에서 사용하는 로거들을 설정합니다. + # -------------------------------------------------------- + "loggers": { + # Uvicorn 메인 로거 + "uvicorn": { + "handlers": ["default"], + "level": "INFO", + "propagate": False, # 상위 로거로 전파 방지 + }, + # 에러/시작/종료 로그 + "uvicorn.error": { + "handlers": ["default"], + "level": "INFO", + "propagate": False, + }, + # HTTP 요청 로그 (GET /path HTTP/1.1 200 등) + "uvicorn.access": { + "handlers": ["access"], + "level": "INFO", + "propagate": False, + }, + }, + } + + +# 편의를 위한 사전 정의된 로거 이름 상수 +HOME_LOGGER = "home" +LYRIC_LOGGER = "lyric" +SONG_LOGGER = "song" +VIDEO_LOGGER = "video" +CELERY_LOGGER = "celery" +APP_LOGGER = "app" diff --git a/app/utils/nvMapScraper.py b/app/utils/nvMapScraper.py index 7eec1bf..d16f8f1 100644 --- a/app/utils/nvMapScraper.py +++ b/app/utils/nvMapScraper.py @@ -1,15 +1,15 @@ import asyncio import json -import logging import re import aiohttp import bs4 +from app.utils.logger import get_logger from config import crawler_settings # 로거 설정 -logger = logging.getLogger(__name__) +logger = get_logger("scraper") class GraphQLException(Exception): @@ -109,7 +109,7 @@ query getAccommodation($id: String!, $deviceType: String) { self.scrap_type = "GraphQL" except GraphQLException: - print("fallback") + logger.debug("GraphQL failed, fallback to Playwright") self.scrap_type = "Playwright" pass # 나중에 pw 이용한 crawling으로 fallback 추가 @@ -138,7 +138,6 @@ query getAccommodation($id: String!, $deviceType: String) { try: logger.info(f"[NvMapScraper] Requesting place_id: {place_id}") - print(f"[NvMapScraper] Requesting place_id: {place_id}") async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post( @@ -148,24 +147,20 @@ query getAccommodation($id: String!, $deviceType: String) { ) as response: if response.status == 200: logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}") - print(f"[NvMapScraper] SUCCESS - place_id: {place_id}") return await response.json() # 실패 상태 코드 logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}") - print(f"[NvMapScraper] 실패 상태 코드: {response.status}") raise GraphQLException( f"Request failed with status {response.status}" ) except (TimeoutError, asyncio.TimeoutError): logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}") - print(f"[NvMapScraper] Timeout - place_id: {place_id}") raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s") except aiohttp.ClientError as e: logger.error(f"[NvMapScraper] Client error: {e}") - print(f"[NvMapScraper] Client error: {e}") raise GraphQLException(f"Client error: {e}") async def _get_facility_string(self, place_id: str) -> str | None: diff --git a/app/utils/upload_blob_as_request.py b/app/utils/upload_blob_as_request.py index 921e4e9..da4b01b 100644 --- a/app/utils/upload_blob_as_request.py +++ b/app/utils/upload_blob_as_request.py @@ -32,17 +32,17 @@ URL 경로 형식: """ import asyncio -import logging import time from pathlib import Path import aiofiles import httpx +from app.utils.logger import get_logger from config import azure_blob_settings # 로거 설정 -logger = logging.getLogger(__name__) +logger = get_logger("blob") # ============================================================================= # 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴) @@ -56,13 +56,13 @@ async def get_shared_blob_client() -> httpx.AsyncClient: """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" global _shared_blob_client if _shared_blob_client is None or _shared_blob_client.is_closed: - print("[AzureBlobUploader] Creating shared HTTP client...") + logger.info("[AzureBlobUploader] Creating shared HTTP client...") _shared_blob_client = httpx.AsyncClient( timeout=httpx.Timeout(180.0, connect=10.0), limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), ) - print("[AzureBlobUploader] Shared HTTP client created - " - "max_connections: 20, max_keepalive: 10") + logger.info("[AzureBlobUploader] Shared HTTP client created - " + "max_connections: 20, max_keepalive: 10") return _shared_blob_client @@ -72,7 +72,7 @@ async def close_shared_blob_client() -> None: if _shared_blob_client is not None and not _shared_blob_client.is_closed: await _shared_blob_client.aclose() _shared_blob_client = None - print("[AzureBlobUploader] Shared HTTP client closed") + logger.info("[AzureBlobUploader] Shared HTTP client closed") class AzureBlobUploader: @@ -158,15 +158,15 @@ class AzureBlobUploader: try: logger.info(f"[{log_prefix}] Starting upload") - print(f"[{log_prefix}] Getting shared client...") + logger.debug(f"[{log_prefix}] Getting shared client...") client = await get_shared_blob_client() client_time = time.perf_counter() elapsed_ms = (client_time - start_time) * 1000 - print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms") + logger.debug(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms") - print(f"[{log_prefix}] Starting upload... " - f"(size: {size} bytes, timeout: {timeout}s)") + logger.debug(f"[{log_prefix}] Starting upload... " + f"(size: {size} bytes, timeout: {timeout}s)") response = await asyncio.wait_for( client.put(upload_url, content=file_content, headers=headers), @@ -176,44 +176,38 @@ class AzureBlobUploader: duration_ms = (upload_time - start_time) * 1000 if response.status_code in [200, 201]: - logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}") - print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, " - f"Duration: {duration_ms:.1f}ms") - print(f"[{log_prefix}] Public URL: {self._last_public_url}") + logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, " + f"Duration: {duration_ms:.1f}ms") + logger.debug(f"[{log_prefix}] Public URL: {self._last_public_url}") return True # 업로드 실패 - logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}") - print(f"[{log_prefix}] FAILED - Status: {response.status_code}, " - f"Duration: {duration_ms:.1f}ms") - print(f"[{log_prefix}] Response: {response.text[:500]}") + logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}, " + f"Duration: {duration_ms:.1f}ms") + logger.error(f"[{log_prefix}] Response: {response.text[:500]}") return False except asyncio.TimeoutError: elapsed = time.perf_counter() - start_time logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s") - print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s") return False except httpx.ConnectError as e: elapsed = time.perf_counter() - start_time - logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}") - print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - " - f"{type(e).__name__}: {e}") + logger.error(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - " + f"{type(e).__name__}: {e}") return False except httpx.ReadError as e: elapsed = time.perf_counter() - start_time - logger.error(f"[{log_prefix}] READ_ERROR: {e}") - print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - " - f"{type(e).__name__}: {e}") + logger.error(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - " + f"{type(e).__name__}: {e}") return False except Exception as e: elapsed = time.perf_counter() - start_time - logger.error(f"[{log_prefix}] ERROR: {type(e).__name__}: {e}") - print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - " - f"{type(e).__name__}: {e}") + logger.error(f"[{log_prefix}] ERROR after {elapsed:.1f}s - " + f"{type(e).__name__}: {e}") return False async def _upload_file( @@ -241,7 +235,7 @@ class AzureBlobUploader: upload_url = self._build_upload_url(category, file_name) self._last_public_url = self._build_public_url(category, file_name) - print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") + logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"} @@ -306,7 +300,7 @@ class AzureBlobUploader: upload_url = self._build_upload_url("song", file_name) self._last_public_url = self._build_public_url("song", file_name) log_prefix = "upload_music_bytes" - print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") + logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"} @@ -368,7 +362,7 @@ class AzureBlobUploader: upload_url = self._build_upload_url("video", file_name) self._last_public_url = self._build_public_url("video", file_name) log_prefix = "upload_video_bytes" - print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") + logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"} @@ -434,7 +428,7 @@ class AzureBlobUploader: upload_url = self._build_upload_url("image", file_name) self._last_public_url = self._build_public_url("image", file_name) log_prefix = "upload_image_bytes" - print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") + logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"} diff --git a/app/video/api/routers/v1/__init__.py b/app/video/api/routers/v1/__init__.py index e69de29..aa27ccd 100644 --- a/app/video/api/routers/v1/__init__.py +++ b/app/video/api/routers/v1/__init__.py @@ -0,0 +1,3 @@ +""" +Video API v1 라우터 모듈 +""" diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 053ca03..58214d1 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -39,9 +39,11 @@ from app.video.schemas.video_schema import ( from app.video.worker.video_task import download_and_upload_video_to_blob from app.utils.creatomate import CreatomateService from app.utils.pagination import PaginatedResponse +from app.utils.logger import get_logger +logger = get_logger("video") -router = APIRouter(prefix="/video", tags=["video"]) +router = APIRouter(prefix="/video", tags=["Video"]) @router.get( @@ -115,7 +117,7 @@ async def generate_video( from app.database.session import AsyncSessionLocal request_start = time.perf_counter() - print(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}") + logger.info(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}") # ========================================================================== # 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음) @@ -168,13 +170,13 @@ async def generate_video( ) query_time = time.perf_counter() - print(f"[generate_video] Queries completed - task_id: {task_id}, " + logger.debug(f"[generate_video] Queries completed - task_id: {task_id}, " f"elapsed: {(query_time - request_start)*1000:.1f}ms") # ===== 결과 처리: Project ===== project = project_result.scalar_one_or_none() if not project: - print(f"[generate_video] Project NOT FOUND - task_id: {task_id}") + logger.warning(f"[generate_video] Project NOT FOUND - task_id: {task_id}") raise HTTPException( status_code=404, detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", @@ -184,7 +186,7 @@ async def generate_video( # ===== 결과 처리: Lyric ===== lyric = lyric_result.scalar_one_or_none() if not lyric: - print(f"[generate_video] Lyric NOT FOUND - task_id: {task_id}") + logger.warning(f"[generate_video] Lyric NOT FOUND - task_id: {task_id}") raise HTTPException( status_code=404, detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", @@ -194,7 +196,7 @@ async def generate_video( # ===== 결과 처리: Song ===== song = song_result.scalar_one_or_none() if not song: - print(f"[generate_video] Song NOT FOUND - task_id: {task_id}") + logger.warning(f"[generate_video] Song NOT FOUND - task_id: {task_id}") raise HTTPException( status_code=404, detail=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.", @@ -220,14 +222,14 @@ async def generate_video( # ===== 결과 처리: Image ===== images = image_result.scalars().all() if not images: - print(f"[generate_video] Image NOT FOUND - task_id: {task_id}") + logger.warning(f"[generate_video] Image NOT FOUND - task_id: {task_id}") raise HTTPException( status_code=404, detail=f"task_id '{task_id}'에 해당하는 이미지를 찾을 수 없습니다.", ) image_urls = [img.img_url for img in images] - print( + logger.info( f"[generate_video] Data loaded - task_id: {task_id}, " f"project_id: {project_id}, lyric_id: {lyric_id}, " f"song_id: {song_id}, images: {len(image_urls)}" @@ -246,14 +248,14 @@ async def generate_video( await session.commit() video_id = video.id stage1_time = time.perf_counter() - print(f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, " + logger.info(f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, " f"stage1_elapsed: {(stage1_time - request_start)*1000:.1f}ms") # 세션이 여기서 자동으로 닫힘 (async with 블록 종료) except HTTPException: raise except Exception as e: - print(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}") + logger.error(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}") return GenerateVideoResponse( success=False, task_id=task_id, @@ -267,16 +269,16 @@ async def generate_video( # ========================================================================== stage2_start = time.perf_counter() try: - print(f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}") + logger.info(f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}") creatomate_service = CreatomateService( orientation=orientation, target_duration=song_duration, ) - print(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})") + logger.debug(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})") # 6-1. 템플릿 조회 (비동기) template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id) - print(f"[generate_video] Template fetched - task_id: {task_id}") + logger.debug(f"[generate_video] Template fetched - task_id: {task_id}") # 6-2. elements에서 리소스 매핑 생성 modifications = creatomate_service.elements_connect_resource_blackbox( @@ -285,7 +287,7 @@ async def generate_video( lyric=lyrics, music_url=music_url, ) - print(f"[generate_video] Modifications created - task_id: {task_id}") + logger.debug(f"[generate_video] Modifications created - task_id: {task_id}") # 6-3. elements 수정 new_elements = creatomate_service.modify_element( @@ -293,20 +295,20 @@ async def generate_video( modifications, ) template["source"]["elements"] = new_elements - print(f"[generate_video] Elements modified - task_id: {task_id}") + logger.debug(f"[generate_video] Elements modified - task_id: {task_id}") # 6-4. duration 확장 final_template = creatomate_service.extend_template_duration( template, creatomate_service.target_duration, ) - print(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}") + logger.debug(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}") # 6-5. 커스텀 렌더링 요청 (비동기) render_response = await creatomate_service.make_creatomate_custom_call_async( final_template["source"], ) - print(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}") + logger.debug(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}") # 렌더 ID 추출 if isinstance(render_response, list) and len(render_response) > 0: @@ -317,14 +319,14 @@ async def generate_video( creatomate_render_id = None stage2_time = time.perf_counter() - print( + logger.info( f"[generate_video] Stage 2 DONE - task_id: {task_id}, " f"render_id: {creatomate_render_id}, " f"stage2_elapsed: {(stage2_time - stage2_start)*1000:.1f}ms" ) except Exception as e: - print(f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}") + logger.error(f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}") # 외부 API 실패 시 Video 상태를 failed로 업데이트 from app.database.session import AsyncSessionLocal async with AsyncSessionLocal() as update_session: @@ -347,7 +349,7 @@ async def generate_video( # 3단계: creatomate_render_id 업데이트 (새 세션으로 빠르게 처리) # ========================================================================== stage3_start = time.perf_counter() - print(f"[generate_video] Stage 3 START - DB update - task_id: {task_id}") + logger.info(f"[generate_video] Stage 3 START - DB update - task_id: {task_id}") try: from app.database.session import AsyncSessionLocal async with AsyncSessionLocal() as update_session: @@ -361,11 +363,11 @@ async def generate_video( stage3_time = time.perf_counter() total_time = stage3_time - request_start - print( + logger.debug( f"[generate_video] Stage 3 DONE - task_id: {task_id}, " f"stage3_elapsed: {(stage3_time - stage3_start)*1000:.1f}ms" ) - print( + logger.info( f"[generate_video] SUCCESS - task_id: {task_id}, " f"render_id: {creatomate_render_id}, " f"total_time: {total_time*1000:.1f}ms" @@ -380,7 +382,7 @@ async def generate_video( ) except Exception as e: - print(f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}") + logger.error(f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}") return GenerateVideoResponse( success=False, task_id=task_id, @@ -439,11 +441,11 @@ async def get_video_status( succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 Video 테이블의 status를 completed로, result_movie_url을 업데이트합니다. """ - print(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}") + logger.info(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}") try: creatomate_service = CreatomateService() result = await creatomate_service.get_render_status_async(creatomate_render_id) - print(f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}") + logger.debug(f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}") status = result.get("status", "unknown") video_url = result.get("url") @@ -481,7 +483,7 @@ async def get_video_status( store_name = project.store_name if project else "video" # 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제 - print(f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}") + logger.info(f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}") background_tasks.add_task( download_and_upload_video_to_blob, task_id=video.task_id, @@ -489,7 +491,7 @@ async def get_video_status( store_name=store_name, ) elif video and video.status == "completed": - print(f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}") + logger.debug(f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}") render_data = VideoRenderData( id=result.get("id"), @@ -498,7 +500,7 @@ async def get_video_status( snapshot_url=result.get("snapshot_url"), ) - print(f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}") + logger.info(f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}") return PollingVideoResponse( success=True, status=status, @@ -511,7 +513,7 @@ async def get_video_status( except Exception as e: import traceback - print(f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") + logger.error(f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") return PollingVideoResponse( success=False, status="error", @@ -563,7 +565,7 @@ async def download_video( session: AsyncSession = Depends(get_session), ) -> DownloadVideoResponse: """task_id로 Video 상태를 polling하고 completed 시 Project 정보와 영상 URL을 반환합니다.""" - print(f"[download_video] START - task_id: {task_id}") + logger.info(f"[download_video] START - task_id: {task_id}") try: # task_id로 Video 조회 (여러 개 있을 경우 가장 최근 것 선택) video_result = await session.execute( @@ -575,7 +577,7 @@ async def download_video( video = video_result.scalar_one_or_none() if not video: - print(f"[download_video] Video NOT FOUND - task_id: {task_id}") + logger.warning(f"[download_video] Video NOT FOUND - task_id: {task_id}") return DownloadVideoResponse( success=False, status="not_found", @@ -583,11 +585,11 @@ async def download_video( error_message="Video not found", ) - print(f"[download_video] Video found - task_id: {task_id}, status: {video.status}") + logger.debug(f"[download_video] Video found - task_id: {task_id}, status: {video.status}") # processing 상태인 경우 if video.status == "processing": - print(f"[download_video] PROCESSING - task_id: {task_id}") + logger.debug(f"[download_video] PROCESSING - task_id: {task_id}") return DownloadVideoResponse( success=True, status="processing", @@ -597,7 +599,7 @@ async def download_video( # failed 상태인 경우 if video.status == "failed": - print(f"[download_video] FAILED - task_id: {task_id}") + logger.error(f"[download_video] FAILED - task_id: {task_id}") return DownloadVideoResponse( success=False, status="failed", @@ -612,7 +614,7 @@ async def download_video( ) project = project_result.scalar_one_or_none() - print(f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}") + logger.info(f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}") return DownloadVideoResponse( success=True, status="completed", @@ -625,7 +627,7 @@ async def download_video( ) except Exception as e: - print(f"[download_video] EXCEPTION - task_id: {task_id}, error: {e}") + logger.error(f"[download_video] EXCEPTION - task_id: {task_id}, error: {e}") return DownloadVideoResponse( success=False, status="error", @@ -674,7 +676,7 @@ async def get_videos( pagination: PaginationParams = Depends(get_pagination_params), ) -> PaginatedResponse[VideoListItem]: """완료된 영상 목록을 페이지네이션하여 반환합니다.""" - print(f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}") + logger.info(f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}") try: offset = (pagination.page - 1) * pagination.page_size @@ -732,14 +734,14 @@ async def get_videos( page_size=pagination.page_size, ) - print( + logger.info( f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, " f"page_size: {pagination.page_size}, items_count: {len(items)}" ) return response except Exception as e: - print(f"[get_videos] EXCEPTION - error: {e}") + logger.error(f"[get_videos] EXCEPTION - error: {e}") raise HTTPException( status_code=500, detail=f"영상 목록 조회에 실패했습니다: {str(e)}", diff --git a/app/video/dependencies.py b/app/video/dependencies.py index d03c265..e69de29 100644 --- a/app/video/dependencies.py +++ b/app/video/dependencies.py @@ -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)] diff --git a/app/video/services/video.py b/app/video/services/video.py index fd2c6c0..ba9ea5f 100644 --- a/app/video/services/video.py +++ b/app/video/services/video.py @@ -14,6 +14,9 @@ from app.lyrics.schemas.lyrics_schema import ( StoreData, ) from app.utils.chatgpt_prompt import chatgpt_api +from app.utils.logger import get_logger + +logger = get_logger("video") async def get_store_info(conn: Connection) -> List[StoreData]: @@ -38,13 +41,13 @@ async def get_store_info(conn: Connection) -> List[StoreData]: result.close() return all_store_info except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemy error in get_store_info: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_store_info: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -69,13 +72,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]: result.close() return all_attribute except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemy error in get_attribute: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_attribute: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -100,13 +103,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]: result.close() return all_attribute except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemy error in get_attribute: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_attribute: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -132,13 +135,13 @@ async def get_sample_song(conn: Connection) -> List[SongSampleData]: result.close() return all_sample_song except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemy error in get_sample_song: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_sample_song: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -162,13 +165,13 @@ async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]: result.close() return all_prompt_template except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemy error in get_prompt_template: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_prompt_template: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -192,13 +195,13 @@ async def get_song_result(conn: Connection) -> List[PromptTemplateData]: result.close() return all_prompt_template except SQLAlchemyError as e: - print(e) + logger.error(f"SQLAlchemy error in get_song_result: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", ) except Exception as e: - print(e) + logger.error(f"Unexpected error in get_song_result: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다", @@ -210,11 +213,11 @@ async def make_song_result(request: Request, conn: Connection): # 1. Form 데이터 파싱 form_data = await SongFormData.from_form(request) - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"Lyrics IDs: {form_data.lyrics_ids}") - print(f"Prompt IDs: {form_data.prompts}") - print(f"{'=' * 60}\n") + logger.info(f"{'=' * 60}") + logger.info(f"Store ID: {form_data.store_id}") + logger.info(f"Lyrics IDs: {form_data.lyrics_ids}") + logger.info(f"Prompt IDs: {form_data.prompts}") + logger.info(f"{'=' * 60}") # 2. Store 정보 조회 store_query = """ @@ -243,7 +246,7 @@ async def make_song_result(request: Request, conn: Connection): ) store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") + logger.info(f"Store: {store_info.store_name}") # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 @@ -251,7 +254,7 @@ async def make_song_result(request: Request, conn: Connection): combined_sample_song = None if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") lyrics_query = """ SELECT sample_song FROM song_sample @@ -270,11 +273,11 @@ async def make_song_result(request: Request, conn: Connection): combined_sample_song = "\n\n".join( [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료") else: - print("샘플 가사가 비어있습니다") + logger.info("샘플 가사가 비어있습니다") else: - print("선택된 lyrics가 없습니다") + logger.info("선택된 lyrics가 없습니다") # 5. 템플릿 가져오기 if not form_data.prompts: @@ -283,7 +286,7 @@ async def make_song_result(request: Request, conn: Connection): detail="프롬프트 ID가 필요합니다", ) - print("템플릿 가져오기") + logger.info("템플릿 가져오기") prompts_query = """ SELECT * FROM prompt_template WHERE id=:id; @@ -310,7 +313,7 @@ async def make_song_result(request: Request, conn: Connection): ) prompt = prompts_info[0] - print(f"Prompt Template: {prompt.prompt}") + logger.debug(f"Prompt Template: {prompt.prompt}") # ✅ 6. 프롬프트 조합 updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( @@ -329,7 +332,7 @@ async def make_song_result(request: Request, conn: Connection): {combined_sample_song} """ - print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") + logger.debug(f"[업데이트된 프롬프트]\n{updated_prompt}") # 7. 모델에게 요청 generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) @@ -348,13 +351,12 @@ async def make_song_result(request: Request, conn: Connection): 전체 글자 수 (공백 포함): {total_chars_with_space}자 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" - print("=" * 40) - print("[translate:form_data.attributes_str:] ", form_data.attributes_str) - print("[translate:total_chars_with_space:] ", total_chars_with_space) - print("[translate:total_chars_without_space:] ", total_chars_without_space) - print("[translate:final_lyrics:]") - print(final_lyrics) - print("=" * 40) + logger.debug("=" * 40) + logger.debug(f"[translate:form_data.attributes_str:] {form_data.attributes_str}") + logger.debug(f"[translate:total_chars_with_space:] {total_chars_with_space}") + logger.debug(f"[translate:total_chars_without_space:] {total_chars_without_space}") + logger.debug(f"[translate:final_lyrics:]\n{final_lyrics}") + logger.debug("=" * 40) # 8. DB 저장 insert_query = """ @@ -396,13 +398,13 @@ async def make_song_result(request: Request, conn: Connection): await conn.execute(text(insert_query), insert_params) await conn.commit() - print("결과 저장 완료") + logger.info("결과 저장 완료") - print("\n전체 결과 조회 중...") + logger.info("전체 결과 조회 중...") # 9. 생성 결과 가져오기 (created_at 역순) select_query = """ - SELECT * FROM song_results_all + SELECT * FROM song_results_all ORDER BY created_at DESC; """ @@ -430,26 +432,20 @@ async def make_song_result(request: Request, conn: Connection): for row in all_results.fetchall() ] - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + logger.info(f"전체 {len(results_list)}개의 결과 조회 완료") return results_list except HTTPException: raise except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"Database Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="데이터베이스 연결에 문제가 발생했습니다.", ) except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"Unexpected Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="서비스 처리 중 오류가 발생했습니다.", @@ -490,25 +486,19 @@ async def get_song_result(conn: Connection): # 반환 타입 수정 for row in all_results.fetchall() ] - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + logger.info(f"전체 {len(results_list)}개의 결과 조회 완료") return results_list except HTTPException: # HTTPException은 그대로 raise raise except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"Database Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="데이터베이스 연결에 문제가 발생했습니다.", ) except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"Unexpected Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="서비스 처리 중 오류가 발생했습니다.", @@ -520,9 +510,9 @@ async def make_automation(request: Request, conn: Connection): # 1. Form 데이터 파싱 form_data = await SongFormData.from_form(request) - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"{'=' * 60}\n") + logger.info(f"{'=' * 60}") + logger.info(f"Store ID: {form_data.store_id}") + logger.info(f"{'=' * 60}") # 2. Store 정보 조회 store_query = """ @@ -551,7 +541,7 @@ async def make_automation(request: Request, conn: Connection): ) store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") + logger.info(f"Store: {store_info.store_name}") # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 attribute_query = """ @@ -596,13 +586,13 @@ async def make_automation(request: Request, conn: Connection): # 최종 문자열 생성 formatted_attributes = "\n".join(formatted_pairs) - print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") + logger.debug(f"[포맷팅된 문자열 속성 정보]\n{formatted_attributes}") else: - print("속성 데이터가 없습니다") + logger.info("속성 데이터가 없습니다") formatted_attributes = "" # 4. 템플릿 가져오기 - print("템플릿 가져오기 (ID=1)") + logger.info("템플릿 가져오기 (ID=1)") prompts_query = """ SELECT * FROM prompt_template WHERE id=1; @@ -624,7 +614,7 @@ async def make_automation(request: Request, conn: Connection): prompt=row[2], ) - print(f"Prompt Template: {prompt.prompt}") + logger.debug(f"Prompt Template: {prompt.prompt}") # 5. 템플릿 조합 @@ -635,17 +625,17 @@ async def make_automation(request: Request, conn: Connection): description=store_info.store_info or "", ) - print("\n" + "=" * 80) - print("업데이트된 프롬프트") - print("=" * 80) - print(updated_prompt) - print("=" * 80 + "\n") + logger.debug("=" * 80) + logger.debug("업데이트된 프롬프트") + logger.debug("=" * 80) + logger.debug(updated_prompt) + logger.debug("=" * 80) # 4. Sample Song 조회 및 결합 combined_sample_song = None if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") lyrics_query = """ SELECT sample_song FROM song_sample @@ -664,14 +654,14 @@ async def make_automation(request: Request, conn: Connection): combined_sample_song = "\n\n".join( [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료") else: - print("샘플 가사가 비어있습니다") + logger.info("샘플 가사가 비어있습니다") else: - print("선택된 lyrics가 없습니다") + logger.info("선택된 lyrics가 없습니다") # 1. song_sample 테이블의 모든 ID 조회 - print("\n[샘플 가사 랜덤 선택]") + logger.info("[샘플 가사 랜덤 선택]") all_ids_query = """ SELECT id FROM song_sample; @@ -679,7 +669,7 @@ async def make_automation(request: Request, conn: Connection): ids_result = await conn.execute(text(all_ids_query)) all_ids = [row.id for row in ids_result.fetchall()] - print(f"전체 샘플 가사 개수: {len(all_ids)}개") + logger.info(f"전체 샘플 가사 개수: {len(all_ids)}개") # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) combined_sample_song = None @@ -689,7 +679,7 @@ async def make_automation(request: Request, conn: Connection): sample_count = min(3, len(all_ids)) selected_ids = random.sample(all_ids, sample_count) - print(f"랜덤 선택된 ID: {selected_ids}") + logger.info(f"랜덤 선택된 ID: {selected_ids}") # 3. 선택된 ID로 샘플 가사 조회 lyrics_query = """ @@ -710,11 +700,11 @@ async def make_automation(request: Request, conn: Connection): combined_sample_song = "\n\n".join( [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료") else: - print("샘플 가사가 비어있습니다") + logger.info("샘플 가사가 비어있습니다") else: - print("song_sample 테이블에 데이터가 없습니다") + logger.info("song_sample 테이블에 데이터가 없습니다") # 5. 프롬프트에 샘플 가사 추가 if combined_sample_song: @@ -726,11 +716,11 @@ async def make_automation(request: Request, conn: Connection): {combined_sample_song} """ - print("샘플 가사 정보가 프롬프트에 추가되었습니다") + logger.info("샘플 가사 정보가 프롬프트에 추가되었습니다") else: - print("샘플 가사가 없어 기본 프롬프트만 사용합니다") + logger.info("샘플 가사가 없어 기본 프롬프트만 사용합니다") - print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") + logger.debug(f"[최종 프롬프트 길이: {len(updated_prompt)} 자]") # 7. 모델에게 요청 generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) @@ -763,10 +753,9 @@ async def make_automation(request: Request, conn: Connection): :sample_song, :result_song, NOW() ); """ - print("\n[insert_params 선택된 속성 확인]") - print(f"Categories: {selected_categories}") - print(f"Values: {selected_values}") - print() + logger.debug("[insert_params 선택된 속성 확인]") + logger.debug(f"Categories: {selected_categories}") + logger.debug(f"Values: {selected_values}") # attr_category, attr_value insert_params = { @@ -792,9 +781,9 @@ async def make_automation(request: Request, conn: Connection): await conn.execute(text(insert_query), insert_params) await conn.commit() - print("결과 저장 완료") + logger.info("결과 저장 완료") - print("\n전체 결과 조회 중...") + logger.info("전체 결과 조회 중...") # 9. 생성 결과 가져오기 (created_at 역순) select_query = """ @@ -826,26 +815,20 @@ async def make_automation(request: Request, conn: Connection): for row in all_results.fetchall() ] - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + logger.info(f"전체 {len(results_list)}개의 결과 조회 완료") return results_list except HTTPException: raise except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"Database Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="데이터베이스 연결에 문제가 발생했습니다.", ) except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() + logger.error(f"Unexpected Error: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="서비스 처리 중 오류가 발생했습니다.", diff --git a/app/video/worker/video_task.py b/app/video/worker/video_task.py index cca4b17..7edc2c2 100644 --- a/app/video/worker/video_task.py +++ b/app/video/worker/video_task.py @@ -4,7 +4,6 @@ Video Background Tasks 영상 생성 관련 백그라운드 태스크를 정의합니다. """ -import logging import traceback from pathlib import Path @@ -16,9 +15,10 @@ from sqlalchemy.exc import SQLAlchemyError from app.database.session import BackgroundSessionLocal from app.video.models import Video from app.utils.upload_blob_as_request import AzureBlobUploader +from app.utils.logger import get_logger # 로거 설정 -logger = logging.getLogger(__name__) +logger = get_logger("video") # HTTP 요청 설정 REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분) @@ -66,20 +66,16 @@ async def _update_video_status( video.result_movie_url = video_url await session.commit() logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}") - print(f"[Video] Status updated - task_id: {task_id}, status: {status}") return True else: logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}") - print(f"[Video] NOT FOUND in DB - task_id: {task_id}") return False except SQLAlchemyError as e: logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}") - print(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}") return False except Exception as e: logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}") - print(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}") return False @@ -97,14 +93,12 @@ async def _download_video(url: str, task_id: str) -> bytes: httpx.HTTPError: 다운로드 실패 시 """ logger.info(f"[VideoDownload] Downloading - task_id: {task_id}") - print(f"[VideoDownload] Downloading - task_id: {task_id}") async with httpx.AsyncClient() as client: response = await client.get(url, timeout=REQUEST_TIMEOUT) response.raise_for_status() logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") - print(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") return response.content @@ -121,7 +115,6 @@ async def download_and_upload_video_to_blob( store_name: 저장할 파일명에 사용할 업체명 """ logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}") - print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}") temp_file_path: Path | None = None try: @@ -136,12 +129,10 @@ async def download_and_upload_video_to_blob( temp_dir = Path("media") / "temp" / task_id temp_dir.mkdir(parents=True, exist_ok=True) temp_file_path = temp_dir / file_name - logger.info(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}") - print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}") + logger.debug(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}") # 영상 파일 다운로드 logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}") - print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}") content = await _download_video(video_url, task_id) @@ -149,7 +140,6 @@ async def download_and_upload_video_to_blob( await f.write(content) logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") - print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") # Azure Blob Storage에 업로드 uploader = AzureBlobUploader(task_id=task_id) @@ -161,29 +151,21 @@ async def download_and_upload_video_to_blob( # SAS 토큰이 제외된 public_url 사용 blob_url = uploader.public_url logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") - print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") # Video 테이블 업데이트 await _update_video_status(task_id, "completed", blob_url) logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}") - print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}") except httpx.HTTPError as e: - logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") - print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True) await _update_video_status(task_id, "failed") except SQLAlchemyError as e: - logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}") - print(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True) await _update_video_status(task_id, "failed") except Exception as e: - logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") - print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True) await _update_video_status(task_id, "failed") finally: @@ -191,11 +173,9 @@ async def download_and_upload_video_to_blob( if temp_file_path and temp_file_path.exists(): try: temp_file_path.unlink() - logger.info(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}") - print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}") + logger.debug(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}") except Exception as e: logger.warning(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}") - print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}") # 임시 디렉토리 삭제 시도 temp_dir = Path("media") / "temp" / task_id @@ -219,7 +199,6 @@ async def download_and_upload_video_by_creatomate_render_id( store_name: 저장할 파일명에 사용할 업체명 """ logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}") - print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}") temp_file_path: Path | None = None task_id: str | None = None @@ -236,12 +215,10 @@ async def download_and_upload_video_by_creatomate_render_id( if not video: logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}") - print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}") return task_id = video.task_id logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}") - print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}") # 파일명에 사용할 수 없는 문자 제거 safe_store_name = "".join( @@ -254,12 +231,10 @@ async def download_and_upload_video_by_creatomate_render_id( temp_dir = Path("media") / "temp" / task_id temp_dir.mkdir(parents=True, exist_ok=True) temp_file_path = temp_dir / file_name - logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}") - print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}") + logger.debug(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}") # 영상 파일 다운로드 logger.info(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}") - print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}") content = await _download_video(video_url, task_id) @@ -267,7 +242,6 @@ async def download_and_upload_video_by_creatomate_render_id( await f.write(content) logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}") - print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}") # Azure Blob Storage에 업로드 uploader = AzureBlobUploader(task_id=task_id) @@ -279,7 +253,6 @@ async def download_and_upload_video_by_creatomate_render_id( # SAS 토큰이 제외된 public_url 사용 blob_url = uploader.public_url logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}") - print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}") # Video 테이블 업데이트 await _update_video_status( @@ -289,26 +262,19 @@ async def download_and_upload_video_by_creatomate_render_id( creatomate_render_id=creatomate_render_id, ) logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}") - print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}") except httpx.HTTPError as e: - logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}") - print(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) if task_id: await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) except SQLAlchemyError as e: - logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}") - print(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) if task_id: await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) except Exception as e: - logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") - print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") - traceback.print_exc() + logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) if task_id: await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) @@ -317,11 +283,9 @@ async def download_and_upload_video_by_creatomate_render_id( if temp_file_path and temp_file_path.exists(): try: temp_file_path.unlink() - logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}") - print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}") + logger.debug(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}") except Exception as e: logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}") - print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}") # 임시 디렉토리 삭제 시도 if task_id: diff --git a/config.py b/config.py index 50cf8b5..046d742 100644 --- a/config.py +++ b/config.py @@ -5,6 +5,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict PROJECT_DIR = Path(__file__).resolve().parent +# 미디어 파일 저장 디렉토리 +MEDIA_ROOT = PROJECT_DIR / "media" +MEDIA_ROOT.mkdir(exist_ok=True) + _base_config = SettingsConfigDict( env_file=PROJECT_DIR / ".env", env_ignore_empty=True, @@ -95,32 +99,6 @@ class DatabaseSettings(BaseSettings): return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}" -class SecuritySettings(BaseSettings): - JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전) - JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전) - - model_config = _base_config - - -class NotificationSettings(BaseSettings): - MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가 - MAIL_PASSWORD: str = "your-email-password" # 기본값 추가 - MAIL_FROM: str = "your-email@example.com" # 기본값 추가 - MAIL_PORT: int = 587 # 기본값 추가 - MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가 - MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가 - MAIL_STARTTLS: bool = True - MAIL_SSL_TLS: bool = False - USE_CREDENTIALS: bool = True - VALIDATE_CERTS: bool = True - - TWILIO_SID: str = "your-twilio-sid" # 기본값 추가 - TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가 - TWILIO_NUMBER: str = "+1234567890" # 기본값 추가 - - model_config = _base_config - - class CrawlerSettings(BaseSettings): NAVER_COOKIES: str = Field(default="") @@ -175,13 +153,236 @@ class PromptSettings(BaseSettings): model_config = _base_config +class KakaoSettings(BaseSettings): + """카카오 OAuth 설정""" + + KAKAO_CLIENT_ID: str = Field(default="", description="카카오 REST API 키") + KAKAO_CLIENT_SECRET: str = Field(default="", description="카카오 Client Secret (선택)") + KAKAO_REDIRECT_URI: str = Field( + default="http://localhost:8000/api/v1/user/auth/kakao/callback", + description="카카오 로그인 후 리다이렉트 URI", + ) + + model_config = _base_config + + +class JWTSettings(BaseSettings): + """JWT 토큰 설정""" + + JWT_SECRET: str = Field( + default="your-super-secret-key-must-be-at-least-32-characters-long", + description="JWT 서명 비밀키 (최소 32자)", + ) + JWT_ALGORITHM: str = Field(default="HS256", description="JWT 알고리즘") + JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field( + default=60, description="액세스 토큰 만료 시간 (분)" + ) + JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field( + default=7, description="리프레시 토큰 만료 시간 (일)" + ) + + model_config = _base_config + + +class LogSettings(BaseSettings): + """ + 로깅 설정 클래스 + + 애플리케이션의 로깅 동작을 제어하는 설정들을 관리합니다. + 모든 설정은 .env 파일 또는 환경변수로 오버라이드 가능합니다. + + 사용 예시 (.env 파일): + LOG_LEVEL=INFO + LOG_CONSOLE_LEVEL=WARNING + LOG_FILE_LEVEL=DEBUG + LOG_DIR=/var/log/myapp + """ + + # ============================================================ + # 로그 디렉토리 설정 + # ============================================================ + # 로그 파일이 저장될 디렉토리 경로입니다. + # - 기본값: 프로젝트 루트의 logs 폴더 + # - 운영 환경에서는 /www/log/uvicorn 또는 /var/log/app 등으로 설정 권장 + # - 디렉토리가 존재하지 않으면 자동으로 생성됩니다. + # - .env 파일에서 LOG_DIR 환경변수로 오버라이드 가능 + LOG_DIR: str = Field( + default="logs", + description="로그 파일 저장 디렉토리 (절대 경로 또는 상대 경로)", + ) + + # ============================================================ + # 로그 출력 대상 설정 + # ============================================================ + # 콘솔 출력 활성화 여부 + # - True: 터미널/콘솔에 로그 출력 + # - False: 콘솔 출력 비활성화 (파일에만 기록) + LOG_CONSOLE_ENABLED: bool = Field( + default=True, + description="콘솔 로그 출력 활성화 여부", + ) + + # 파일 출력 활성화 여부 + # - True: 로그 파일에 기록 (app.log, error.log) + # - False: 파일 출력 비활성화 (콘솔에만 출력) + LOG_FILE_ENABLED: bool = Field( + default=True, + description="파일 로그 출력 활성화 여부", + ) + + # ============================================================ + # 로그 레벨 설정 + # ============================================================ + # 로그 레벨 우선순위 (낮음 → 높음): + # DEBUG < INFO < WARNING < ERROR < CRITICAL + # + # 설정된 레벨 이상의 로그만 출력됩니다. + # 예: INFO로 설정 시 DEBUG는 무시되고, INFO, WARNING, ERROR, CRITICAL만 출력 + # ============================================================ + + # 기본 로그 레벨 + # - 로거 자체의 최소 로그 레벨을 설정합니다. + # - 이 레벨보다 낮은 로그는 핸들러(콘솔/파일)로 전달되지 않습니다. + # - 가능한 값: DEBUG, INFO, WARNING, ERROR, CRITICAL + # - DEBUG: 개발 시 상세 디버깅 정보 (변수 값, 흐름 추적 등) + # - INFO: 일반적인 작업 진행 상황 (요청 시작/완료 등) + # - WARNING: 잠재적 문제 또는 주의가 필요한 상황 + # - ERROR: 오류 발생, 하지만 애플리케이션은 계속 실행 + # - CRITICAL: 심각한 오류, 애플리케이션 중단 가능성 + LOG_LEVEL: str = Field( + default="DEBUG", + description="기본 로그 레벨", + ) + + # 콘솔 출력 로그 레벨 + # - 터미널/콘솔에 출력되는 로그의 최소 레벨을 설정합니다. + # - 개발 환경: DEBUG 권장 (모든 로그 확인) + # - 운영 환경: INFO 또는 WARNING 권장 (중요한 정보만 출력) + # - LOG_LEVEL보다 낮게 설정해도 LOG_LEVEL이 우선 적용됩니다. + LOG_CONSOLE_LEVEL: str = Field( + default="DEBUG", + description="콘솔 출력 로그 레벨", + ) + + # 파일 출력 로그 레벨 + # - 로그 파일에 기록되는 로그의 최소 레벨을 설정합니다. + # - 파일에는 더 상세한 로그를 남기고 싶을 때 DEBUG로 설정 + # - 파일 저장 위치: logs/{날짜}_{모듈명}.log + # - 에러 로그는 별도로 logs/{날짜}_error.log에도 기록됩니다. + LOG_FILE_LEVEL: str = Field( + default="DEBUG", + description="파일 출력 로그 레벨", + ) + + # ============================================================ + # 로그 파일 관리 설정 + # ============================================================ + + # 로그 파일 최대 크기 (MB) + # - 파일이 이 크기를 초과하면 자동으로 새 파일로 롤오버됩니다. + # - 기존 파일은 .1, .2 등의 접미사가 붙어 백업됩니다. + # - 예: 15MB 설정 시, 파일이 15MB를 넘으면 새 파일 생성 + LOG_MAX_SIZE_MB: int = Field( + default=15, + description="로그 파일 최대 크기 (MB)", + ) + + # 로그 파일 백업 개수 + # - 롤오버 시 보관할 백업 파일의 최대 개수입니다. + # - 이 개수를 초과하면 가장 오래된 백업 파일이 삭제됩니다. + # - 예: 30 설정 시, 최대 30개의 백업 파일 유지 + # - 디스크 용량 관리를 위해 적절한 값 설정 권장 + LOG_BACKUP_COUNT: int = Field( + default=30, + description="로그 파일 백업 개수", + ) + + # ============================================================ + # 로그 포맷 설정 + # ============================================================ + # 사용 가능한 포맷 변수: + # {asctime} - 로그 발생 시간 (LOG_DATE_FORMAT 형식) + # {levelname} - 로그 레벨 (DEBUG, INFO 등) + # {name} - 로거 이름 (home, song 등) + # {filename} - 소스 파일명 + # {funcName} - 함수명 + # {lineno} - 라인 번호 + # {message} - 로그 메시지 + # {module} - 모듈명 + # {pathname} - 파일 전체 경로 + # + # 포맷 예시: + # "[{asctime}] {levelname} {message}" + # 출력: [2024-01-14 15:30:00] INFO 서버 시작 + # ============================================================ + + # 콘솔 로그 포맷 + # - 터미널에 출력되는 로그의 형식을 지정합니다. + # - [{levelname}]은 로그 레벨을 대괄호로 감싸서 출력합니다. + LOG_CONSOLE_FORMAT: str = Field( + default="[{asctime}] [{levelname}] [{name}:{funcName}:{lineno}] {message}", + description="콘솔 로그 포맷", + ) + + # 파일 로그 포맷 + # - 파일에 기록되는 로그의 형식을 지정합니다. + # - 파일에는 더 상세한 정보(filename 등)를 포함할 수 있습니다. + LOG_FILE_FORMAT: str = Field( + default="[{asctime}] [{levelname}] [{filename}:{name} -> {funcName}():{lineno}] {message}", + description="파일 로그 포맷", + ) + + # 날짜 포맷 + # - {asctime}에 표시되는 시간의 형식을 지정합니다. + # - Python strftime 형식을 따릅니다. + # - 예시: + # "%Y-%m-%d %H:%M:%S" -> 2024-01-14 15:30:00 + # "%Y/%m/%d %H:%M:%S.%f" -> 2024/01/14 15:30:00.123456 + # "%d-%b-%Y %H:%M:%S" -> 14-Jan-2024 15:30:00 + LOG_DATE_FORMAT: str = Field( + default="%Y-%m-%d %H:%M:%S", + description="로그 날짜 포맷", + ) + + model_config = _base_config + + def get_log_dir(self) -> Path: + """ + 로그 디렉토리 경로를 반환합니다. + + 우선순위: + 1. .env의 LOG_DIR 설정값 (절대 경로인 경우) + 2. /www/log/uvicorn 폴더가 존재하면 사용 (운영 서버) + 3. 프로젝트 루트의 logs 폴더 (개발 환경 기본값) + + Returns: + Path: 로그 디렉토리 경로 (존재하지 않으면 자동 생성) + """ + # 1. .env에서 설정한 경로가 절대 경로인 경우 우선 사용 + log_dir_path = Path(self.LOG_DIR) + if log_dir_path.is_absolute(): + log_dir_path.mkdir(parents=True, exist_ok=True) + return log_dir_path + + # 2. 운영 서버 경로 확인 (/www/log/uvicorn) + production_log_dir = Path("/www/log/uvicorn") + if production_log_dir.exists(): + return production_log_dir + + # 3. 기본값: 프로젝트 루트의 logs 폴더 + default_log_dir = PROJECT_DIR / self.LOG_DIR + default_log_dir.mkdir(parents=True, exist_ok=True) + return default_log_dir + + prj_settings = ProjectSettings() apikey_settings = APIKeySettings() db_settings = DatabaseSettings() -security_settings = SecuritySettings() -notification_settings = NotificationSettings() cors_settings = CORSSettings() crawler_settings = CrawlerSettings() azure_blob_settings = AzureBlobSettings() creatomate_settings = CreatomateSettings() -prompt_settings = PromptSettings() \ No newline at end of file +prompt_settings = PromptSettings() +log_settings = LogSettings() +kakao_settings = KakaoSettings() +jwt_settings = JWTSettings() diff --git a/docs/user/kakao.md b/docs/user/kakao.md new file mode 100644 index 0000000..b286d6b --- /dev/null +++ b/docs/user/kakao.md @@ -0,0 +1,340 @@ +# 카카오 소셜 로그인 구현 가이드 + +## 목차 + +1. [개요](#1-개요) +2. [인증 흐름](#2-인증-흐름) +3. [User 모델 구조](#3-user-모델-구조) +4. [API 엔드포인트](#4-api-엔드포인트) +5. [환경 설정](#5-환경-설정) +6. [에러 처리](#6-에러-처리) + +--- + +## 1. 개요 + +CastAD는 카카오 소셜 로그인만 지원하며, 인증 후 자체 JWT 토큰을 발급합니다. + +### 인증 방식 + +| 항목 | 설명 | +|------|------| +| 소셜 로그인 | 카카오 OAuth 2.0 | +| 자체 인증 | JWT (Access Token + Refresh Token) | +| 카카오 토큰 저장 | X (1회 검증 후 폐기) | + +--- + +## 2. 인증 흐름 + +### 2.1 전체 흐름도 + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ Client │ │ Backend │ │ Kakao │ │ DB │ +└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ │ + │ 1. 로그인 요청 │ │ │ + │──────────────>│ │ │ + │ │ │ │ + │ 2. 카카오 로그인 URL 반환 │ │ + │<──────────────│ │ │ + │ │ │ │ + │ 3. 카카오 로그인 페이지로 이동 │ │ + │──────────────────────────────>│ │ + │ │ │ │ + │ 4. 사용자 인증 후 code 반환 │ │ + │<──────────────────────────────│ │ + │ │ │ │ + │ 5. code 전달 │ │ │ + │──────────────>│ │ │ + │ │ │ │ + │ │ 6. code로 토큰 요청 │ + │ │──────────────>│ │ + │ │ │ │ + │ │ 7. access_token 반환 │ + │ │<──────────────│ │ + │ │ │ │ + │ │ 8. 사용자 정보 조회 │ + │ │──────────────>│ │ + │ │ │ │ + │ │ 9. 사용자 정보 반환 │ + │ │<──────────────│ │ + │ │ │ │ + │ │ 10. kakao_id로 회원 조회 │ + │ │──────────────────────────────>│ + │ │ │ │ + │ │ 11. 회원 정보 반환 (없으면 생성) │ + │ │<──────────────────────────────│ + │ │ │ │ + │ 12. 자체 JWT 발급 및 반환 │ │ + │<──────────────│ │ │ + │ │ │ │ +``` + +### 2.2 단계별 설명 + +| 단계 | 설명 | 관련 API | +|------|------|----------| +| 1-2 | 클라이언트가 로그인 요청, 백엔드가 카카오 인증 URL 생성 | `GET /user/auth/kakao/login` | +| 3-4 | 사용자가 카카오에서 로그인, 인가 코드(code) 발급 | 카카오 OAuth | +| 5-9 | 백엔드가 code로 카카오 토큰/사용자정보 획득 | 카카오 API | +| 10-11 | DB에서 회원 조회, 없으면 신규 가입 | 내부 처리 | +| 12 | 자체 JWT 토큰 발급 후 클라이언트에 반환 | `POST /user/auth/kakao/callback` | + +--- + +## 3. User 모델 구조 + +### 3.1 테이블 스키마 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ user │ +├─────────────────────┬───────────────┬───────────────────────┤ +│ Column │ Type │ Description │ +├─────────────────────┼───────────────┼───────────────────────┤ +│ id │ BIGINT (PK) │ 고유 식별자 (자동증가) │ +│ kakao_id │ BIGINT (UQ) │ 카카오 회원번호 │ +│ email │ VARCHAR(255) │ 이메일 (선택) │ +│ nickname │ VARCHAR(100) │ 닉네임 (선택) │ +│ profile_image_url │ VARCHAR(2048) │ 프로필 이미지 URL │ +│ thumbnail_image_url │ VARCHAR(2048) │ 썸네일 이미지 URL │ +│ is_active │ BOOLEAN │ 계정 활성화 상태 │ +│ is_admin │ BOOLEAN │ 관리자 권한 │ +│ last_login_at │ DATETIME │ 마지막 로그인 일시 │ +│ created_at │ DATETIME │ 생성 일시 │ +│ updated_at │ DATETIME │ 수정 일시 │ +└─────────────────────┴───────────────┴───────────────────────┘ +``` + +### 3.2 카카오 API 응답 매핑 + +```json +// 카카오 API 응답 예시 +{ + "id": 1234567890, + "kakao_account": { + "email": "user@kakao.com", + "profile": { + "nickname": "홍길동", + "profile_image_url": "https://k.kakaocdn.net/.../profile.jpg", + "thumbnail_image_url": "https://k.kakaocdn.net/.../thumb.jpg" + } + } +} +``` + +| User 필드 | 카카오 응답 경로 | +|-----------|-----------------| +| `kakao_id` | `id` | +| `email` | `kakao_account.email` | +| `nickname` | `kakao_account.profile.nickname` | +| `profile_image_url` | `kakao_account.profile.profile_image_url` | +| `thumbnail_image_url` | `kakao_account.profile.thumbnail_image_url` | + +--- + +## 4. API 엔드포인트 + +### 4.1 카카오 로그인 URL 요청 + +``` +GET /user/auth/kakao/login +``` + +**Response:** +```json +{ + "auth_url": "https://kauth.kakao.com/oauth/authorize?client_id=...&redirect_uri=...&response_type=code" +} +``` + +### 4.2 카카오 콜백 (로그인/가입 처리) + +``` +POST /user/auth/kakao/callback +``` + +**Request:** +```json +{ + "code": "인가코드" +} +``` + +**Response (성공):** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs...", + "token_type": "Bearer", + "expires_in": 3600, + "user": { + "id": 1, + "nickname": "홍길동", + "email": "user@kakao.com", + "profile_image_url": "https://...", + "is_new_user": false + } +} +``` + +### 4.3 토큰 갱신 + +``` +POST /user/auth/refresh +``` + +**Request:** +```json +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +**Response:** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +### 4.4 로그아웃 + +``` +POST /user/auth/logout +Authorization: Bearer {access_token} +``` + +### 4.5 모든 기기에서 로그아웃 + +``` +POST /user/auth/logout/all +Authorization: Bearer {access_token} +``` + +### 4.6 내 정보 조회 + +``` +GET /user/auth/me +Authorization: Bearer {access_token} +``` + +**Response:** +```json +{ + "id": 1, + "kakao_id": 1234567890, + "nickname": "홍길동", + "email": "user@kakao.com", + "profile_image_url": "https://...", + "is_admin": false, + "created_at": "2026-01-14T16:00:00" +} +``` + +--- + +## 5. 환경 설정 + +### 5.1 카카오 개발자 설정 + +1. [카카오 개발자 콘솔](https://developers.kakao.com) 접속 +2. 애플리케이션 생성 +3. 플랫폼 > Web 사이트 도메인 등록 +4. 카카오 로그인 > Redirect URI 등록 +5. 동의항목 > 필요한 정보 설정 + +### 5.2 .env 설정 + +```env +# 카카오 OAuth +KAKAO_CLIENT_ID=your_rest_api_key +KAKAO_CLIENT_SECRET=your_client_secret # 선택 +KAKAO_REDIRECT_URI=https://your-domain.com/api/v1/auth/kakao/callback + +# JWT +JWT_SECRET=your-super-secret-key-min-32-characters +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60 +JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 +``` + +### 5.3 config.py 설정 + +```python +class KakaoSettings(BaseSettings): + KAKAO_CLIENT_ID: str = Field(...) + KAKAO_CLIENT_SECRET: str = Field(default="") + KAKAO_REDIRECT_URI: str = Field(...) + + model_config = _base_config + + +class JWTSettings(BaseSettings): + JWT_SECRET: str = Field(...) + JWT_ALGORITHM: str = Field(default="HS256") + JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60) + JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7) + + model_config = _base_config +``` + +--- + +## 6. 에러 처리 + +### 6.1 에러 코드 정의 + +| HTTP Status | Error Code | 설명 | +|-------------|------------|------| +| 400 | `INVALID_CODE` | 유효하지 않은 인가 코드 | +| 400 | `KAKAO_AUTH_FAILED` | 카카오 인증 실패 | +| 401 | `TOKEN_EXPIRED` | 토큰 만료 | +| 401 | `INVALID_TOKEN` | 유효하지 않은 토큰 | +| 401 | `TOKEN_REVOKED` | 취소된 토큰 | +| 403 | `USER_INACTIVE` | 비활성화된 계정 | +| 403 | `ADMIN_REQUIRED` | 관리자 권한 필요 | +| 404 | `USER_NOT_FOUND` | 사용자 없음 | +| 500 | `KAKAO_API_ERROR` | 카카오 API 오류 | + +### 6.2 에러 응답 형식 + +```json +{ + "detail": { + "code": "TOKEN_EXPIRED", + "message": "토큰이 만료되었습니다. 다시 로그인해주세요." + } +} +``` + +--- + +## 부록: 파일 구조 + +``` +app/user/ +├── __init__.py +├── models.py # User, RefreshToken 모델 +├── exceptions.py # 사용자 정의 예외 +├── schemas/ +│ ├── __init__.py +│ └── user_schema.py # Pydantic 스키마 +├── services/ +│ ├── __init__.py +│ ├── auth.py # 인증 서비스 +│ ├── jwt.py # JWT 유틸리티 +│ └── kakao.py # 카카오 OAuth 클라이언트 +├── dependencies/ +│ ├── __init__.py +│ └── auth.py # 인증 의존성 (get_current_user 등) +└── api/ + └── routers/ + └── v1/ + ├── __init__.py + └── auth.py # 인증 API 라우터 +``` diff --git a/main.py b/main.py index 5a149e6..465d951 100644 --- a/main.py +++ b/main.py @@ -1,18 +1,90 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.utils import get_openapi from fastapi.staticfiles import StaticFiles from scalar_fastapi import get_scalar_api_reference from app.admin_manager import init_admin from app.core.common import lifespan from app.database.session import engine + +# 주의: User 모델을 먼저 import해야 UserProject가 User를 참조할 수 있음 +from app.user.models import User, RefreshToken # noqa: F401 + from app.home.api.routers.v1.home import router as home_router +from app.user.api.routers.v1.auth import router as auth_router from app.lyric.api.routers.v1.lyric import router as lyric_router from app.song.api.routers.v1.song import router as song_router from app.video.api.routers.v1.video import router as video_router from app.utils.cors import CustomCORSMiddleware from config import prj_settings +tags_metadata = [ + { + "name": "Auth", + "description": """카카오 소셜 로그인 및 JWT 토큰 관리 API + +## 인증 흐름 + +1. `GET /api/v1/user/auth/kakao/login` - 카카오 로그인 URL 획득 +2. 사용자를 auth_url로 리다이렉트 → 카카오 로그인 +3. 카카오에서 인가 코드(code) 발급 +4. `POST /api/v1/user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급 +5. 이후 API 호출 시 `Authorization: Bearer {access_token}` 헤더 사용 + +## 토큰 관리 + +- **Access Token**: 1시간 유효, API 호출 시 사용 +- **Refresh Token**: 7일 유효, Access Token 갱신 시 사용 +""", + }, + { + "name": "Home", + "description": "홈 화면 및 프로젝트 관리 API", + }, + { + "name": "crawling", + "description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집", + }, + { + "name": "image", + "description": "이미지 업로드 API - 로컬 서버 또는 Azure Blob Storage", + }, + { + "name": "Lyric", + "description": """가사 생성 및 관리 API + +## 가사 생성 흐름 + +1. `POST /api/v1/lyric/generate` - 가사 생성 요청 (백그라운드 처리) +2. `GET /api/v1/lyric/status/{task_id}` - 생성 상태 확인 +3. `GET /api/v1/lyric/{task_id}` - 생성된 가사 조회 +""", + }, + { + "name": "Song", + "description": """노래 생성 및 관리 API (Suno AI) + +## 노래 생성 흐름 + +1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청 +2. `GET /api/v1/song/status/{suno_task_id}` - Suno API 상태 확인 +3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회 +""", + }, + { + "name": "Video", + "description": """영상 생성 및 관리 API (Creatomate) + +## 영상 생성 흐름 + +1. `GET /api/v1/video/generate/{task_id}` - 영상 생성 요청 +2. `GET /api/v1/video/status/{creatomate_render_id}` - Creatomate 상태 확인 +3. `GET /api/v1/video/download/{task_id}` - 영상 다운로드 URL 조회 +""", + }, +] + app = FastAPI( title=prj_settings.PROJECT_NAME, version=prj_settings.VERSION, @@ -20,8 +92,50 @@ app = FastAPI( lifespan=lifespan, docs_url=None, # 기본 Swagger UI 비활성화 redoc_url=None, # 기본 ReDoc 비활성화 + openapi_tags=tags_metadata, ) + +def custom_openapi(): + """커스텀 OpenAPI 스키마 생성 (Bearer 인증 추가)""" + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + tags=tags_metadata, + ) + + # Bearer 토큰 인증 스키마 추가 + openapi_schema["components"]["securitySchemes"] = { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT 액세스 토큰을 입력하세요. 카카오 로그인 후 발급받은 access_token을 사용합니다.", + } + } + + # 보안이 필요한 엔드포인트에 security 적용 + for path, path_item in openapi_schema["paths"].items(): + for method, operation in path_item.items(): + if method in ["get", "post", "put", "patch", "delete"]: + # /auth/me, /auth/logout 등 인증이 필요한 엔드포인트 + if any( + auth_path in path + for auth_path in ["/auth/me", "/auth/logout"] + ): + operation["security"] = [{"BearerAuth": []}] + + app.openapi_schema = openapi_schema + return app.openapi_schema + + +app.openapi = custom_openapi + init_admin(app, engine) custom_cors_middleware = CustomCORSMiddleware(app) @@ -49,6 +163,7 @@ def get_scalar_docs(): app.include_router(home_router) -app.include_router(lyric_router) # Lyric API 라우터 추가 -app.include_router(song_router) # Song API 라우터 추가 -app.include_router(video_router) # Video API 라우터 추가 +app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가 +app.include_router(lyric_router) +app.include_router(song_router) +app.include_router(video_router) diff --git a/pyproject.toml b/pyproject.toml index 8fecce6..1baeaec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "fastapi[standard]>=0.125.0", "openai>=2.13.0", "pydantic-settings>=2.12.0", + "python-jose[cryptography]>=3.5.0", "redis>=7.1.0", "ruff>=0.14.9", "scalar-fastapi>=1.5.0", diff --git a/uv.lock b/uv.lock index 92264f0..ed00052 100644 --- a/uv.lock +++ b/uv.lock @@ -179,6 +179,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -200,6 +245,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -218,6 +319,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + [[package]] name = "email-validator" version = "2.3.0" @@ -769,6 +882,7 @@ dependencies = [ { name = "fastapi-cli" }, { name = "openai" }, { name = "pydantic-settings" }, + { name = "python-jose", extra = ["cryptography"] }, { name = "redis" }, { name = "ruff" }, { name = "scalar-fastapi" }, @@ -794,6 +908,7 @@ requires-dist = [ { name = "fastapi-cli", specifier = ">=0.0.16" }, { name = "openai", specifier = ">=2.13.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "redis", specifier = ">=7.1.0" }, { name = "ruff", specifier = ">=0.14.9" }, { name = "scalar-fastapi", specifier = ">=1.5.0" }, @@ -914,6 +1029,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1056,6 +1189,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + [[package]] name = "python-multipart" version = "0.0.21" @@ -1190,6 +1342,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.14.10" @@ -1247,6 +1411,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1"