Compare commits

..

6 Commits

Author SHA1 Message Date
Dohyun Lim c6fc164c41 Merge branch 'facebook' of https://gitea.o2o.kr/castad/o2o-castad-backend into facebook
마지막 main 머지를 facebook 브렌치로 백업, 저장한 버전
2026-03-13 15:49:38 +09:00
Dohyun Lim 4fbfbf92a6 add exception error cases 2026-03-13 11:22:55 +09:00
Dohyun Lim 50796ac743 Merge facebook into main 2026-03-13 11:04:25 +09:00
Dohyun Lim 9c3f616f37 finish facebook login 2026-03-13 10:59:03 +09:00
Dohyun Lim 0dd0c8595f add fc 2026-03-09 13:54:10 +09:00
Dohyun Lim 6d86aaa1be first commit 2026-03-05 13:29:33 +09:00
49 changed files with 4231 additions and 2568 deletions

3
.gitignore vendored
View File

@ -51,5 +51,4 @@ logs/
Dockerfile Dockerfile
.dockerignore .dockerignore
zzz/ zzz/
credentials/service_account.json

674
WORK_PLAN_FACEBOOK_OAUTH.md Normal file
View File

@ -0,0 +1,674 @@
# Facebook OAuth 구현 작업 계획서
> 작성일: 2026-03-03
> 프로젝트: O2O Castad Backend
> 대상 모듈: `app/sns/` (Facebook OAuth 연동)
---
## 1. 개요
Facebook OAuth 2.0 로그인 기능을 구현합니다.
Facebook Graph API를 통해 사용자 인증, 토큰 교환, 장기 토큰 발급, 페이지 토큰 조회 기능을 제공합니다.
### 참조 공식 문서
- Facebook Manual Login Flow: https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow
- Facebook Graph API: https://developers.facebook.com/docs/graph-api
- Long-Lived Token Exchange: https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived
### Facebook OAuth 2.0 핵심 흐름
```
1. 사용자 → Facebook 인증 페이지 리다이렉트
URL: https://www.facebook.com/v21.0/dialog/oauth
Params: client_id, redirect_uri, state, scope, response_type=code
2. Facebook → 콜백 URL로 인가 코드(code) 전달
3. 서버 → 인가 코드를 액세스 토큰으로 교환
URL: https://graph.facebook.com/v21.0/oauth/access_token
Params: client_id, client_secret, code, redirect_uri
4. 서버 → 단기 토큰을 장기 토큰으로 교환 (약 60일)
URL: https://graph.facebook.com/v21.0/oauth/access_token
Params: grant_type=fb_exchange_token, client_id, client_secret, fb_exchange_token
5. 서버 → 사용자 정보 조회
URL: https://graph.facebook.com/v21.0/me
Params: fields=id,name,email,picture
6. 서버 → 페이지 목록 및 페이지 토큰 조회 (선택)
URL: https://graph.facebook.com/v21.0/{user-id}/accounts
```
---
## 2. 파일 구조 및 역할 분담
```
app/
├── utils/
│ └── facebook_oauth.py # [신규] Facebook OAuth 클래스 (API 통신 전담)
├── sns/
│ ├── api/routers/v1/
│ │ └── oauth.py # [구현] 엔드포인트 정의 (prefix: /sns)
│ ├── services/
│ │ └── facebook.py # [구현] 비즈니스 로직 (facebook_oauth.py 클래스 호출)
│ ├── models.py # [검토/수정] SNSUploadTask 필드 추가 여부 확인
│ ├── dependency.py # [구현] SNS 전용 Depends 정의
│ └── schemas/
│ └── facebook_schema.py # [신규] Facebook OAuth 스키마 정의
├── config.py # [수정] FacebookSettings 독립 클래스 신설 + 인스턴스 생성
├── .env # [수정] FACEBOOK_ 환경변수 추가
└── main.py # [수정] 라우터 등록 및 Scalar 문서 업데이트
```
---
## 3. 상세 작업 단계
### Phase 1: 설정 및 기반 작업
#### Step 1.1: config.py - FacebookSettings 독립 클래스 신설
- **파일**: `config.py`
- **작업**: `FacebookSettings` 독립 클래스를 신규 생성 (기존 `SocialOAuthSettings` 내 주석 코드는 삭제)
- **네이밍 패턴**: 기존 `KakaoSettings`, `JWTSettings` 등과 동일한 패턴 준수
- **환경변수**: 실제 값은 `.env` 파일에서 `FACEBOOK_` prefix 변수로 관리
**config.py에 추가할 클래스** (KakaoSettings 바로 아래에 배치):
```python
class FacebookSettings(BaseSettings):
"""Facebook OAuth 설정
Facebook Graph API를 통한 OAuth 2.0 인증 설정입니다.
Meta for Developers (https://developers.facebook.com/)에서 앱을 생성하고
App ID/Secret을 발급받아야 합니다.
"""
FACEBOOK_APP_ID: str = Field(
default="",
description="Facebook App ID (Meta 개발자 콘솔에서 발급)",
)
FACEBOOK_APP_SECRET: str = Field(
default="",
description="Facebook App Secret",
)
FACEBOOK_REDIRECT_URI: str = Field(
default="http://localhost:8000/sns/facebook/callback",
description="Facebook OAuth 콜백 URI",
)
FACEBOOK_GRAPH_API_VERSION: str = Field(
default="v21.0",
description="Facebook Graph API 버전",
)
FACEBOOK_OAUTH_SCOPE: str = Field(
default="public_profile,email,pages_show_list,pages_read_engagement,pages_manage_posts",
description="Facebook OAuth 요청 권한 범위 (쉼표 구분)",
)
model_config = _base_config
```
**인스턴스 생성** (파일 하단 인스턴스 블록에 추가):
```python
facebook_settings = FacebookSettings()
```
**SocialOAuthSettings 내 기존 Facebook 주석 코드 처리**:
- `SocialOAuthSettings` 내의 Facebook 관련 주석 처리된 코드 블록(524~530행)은 삭제
- 해당 책임이 `FacebookSettings` 클래스로 완전 이관됨
**.env 파일에 추가할 환경변수**:
```env
# ============================================================
# Facebook OAuth 설정
# ============================================================
FACEBOOK_APP_ID=your-facebook-app-id
FACEBOOK_APP_SECRET=your-facebook-app-secret
FACEBOOK_REDIRECT_URI=http://localhost:8000/sns/facebook/callback
FACEBOOK_GRAPH_API_VERSION=v21.0
FACEBOOK_OAUTH_SCOPE=public_profile,email,pages_show_list,pages_read_engagement,pages_manage_posts
```
- **주의**: redirect_uri는 sns 프리픽스 기준으로 설정 (`/sns/facebook/callback`)
---
### Phase 2: OAuth 클라이언트 구현
#### Step 2.1: app/utils/facebook_oauth.py - FacebookOAuthClient 클래스 구현
- **파일**: `app/utils/facebook_oauth.py` (신규)
- **클래스명**: `FacebookOAuthClient` (oauth 단어 포함 필수)
- **역할**: Facebook Graph API와의 HTTP 통신 전담
- **패턴**: `KakaoOAuthClient` (app/user/services/kakao.py) 패턴 준수
- **HTTP 클라이언트**: `httpx.AsyncClient` (기존 Instagram 패턴 사용)
- **Graph API 버전**: v21.0
**클래스 구조**:
```python
from config import facebook_settings
class FacebookOAuthClient:
"""Facebook OAuth 2.0 API 클라이언트"""
# URL 템플릿 (API 버전은 facebook_settings에서 동적 로드)
AUTH_URL_TEMPLATE = "https://www.facebook.com/{version}/dialog/oauth"
TOKEN_URL_TEMPLATE = "https://graph.facebook.com/{version}/oauth/access_token"
USER_INFO_URL_TEMPLATE = "https://graph.facebook.com/{version}/me"
PAGES_URL_TEMPLATE = "https://graph.facebook.com/{version}/{user_id}/accounts"
def __init__(self):
# facebook_settings에서 설정값 로드
self.client_id = facebook_settings.FACEBOOK_APP_ID
self.client_secret = facebook_settings.FACEBOOK_APP_SECRET
self.redirect_uri = facebook_settings.FACEBOOK_REDIRECT_URI
self.api_version = facebook_settings.FACEBOOK_GRAPH_API_VERSION
self.scope = facebook_settings.FACEBOOK_OAUTH_SCOPE
def get_authorization_url(self, state: str) -> str:
# Facebook 인증 페이지 URL 생성
# scope: facebook_settings.FACEBOOK_OAUTH_SCOPE에서 로드
async def get_access_token(self, code: str) -> dict:
# 인가 코드 → 단기 액세스 토큰 교환
# 반환: {access_token, token_type, expires_in}
async def exchange_long_lived_token(self, short_lived_token: str) -> dict:
# 단기 토큰 → 장기 토큰 교환 (약 60일)
# 반환: {access_token, token_type, expires_in}
async def get_user_info(self, access_token: str) -> dict:
# 사용자 프로필 조회
# fields: id, name, email, picture
# 반환: {id, name, email, picture}
async def get_user_pages(self, user_id: str, access_token: str) -> list[dict]:
# 사용자가 관리하는 Facebook 페이지 목록 조회
# 반환: [{id, name, access_token, category}, ...]
```
**예외 클래스** (같은 파일 내 정의):
```python
class FacebookOAuthException(HTTPException):
"""Facebook OAuth 기본 예외"""
class FacebookAuthFailedError(FacebookOAuthException):
"""인증 실패 (400)"""
class FacebookAPIError(FacebookOAuthException):
"""API 호출 오류 (500)"""
class FacebookTokenExpiredError(FacebookOAuthException):
"""토큰 만료 (401)"""
```
**필수 사항**:
- 모든 메서드에 `logger.debug()` 로 입력값, 호출 URL, 응답 상태 기록
- 중요 함수 호출 부분에 한글 주석 추가
- 모듈 로거: `get_logger(__name__)`
---
### Phase 3: 스키마 정의
#### Step 3.1: app/sns/schemas/facebook_schema.py - Facebook 스키마 구현
- **파일**: `app/sns/schemas/facebook_schema.py` (신규)
- **패턴**: 기존 `sns_schema.py` 패턴 준수
**스키마 정의**:
```python
class FacebookConnectResponse(BaseModel):
"""Facebook OAuth 연동 시작 응답"""
auth_url: str # Facebook 인증 페이지 URL
state: str # CSRF 방지용 state 토큰
class FacebookCallbackRequest(BaseModel):
"""Facebook OAuth 콜백 파라미터"""
code: str # 인가 코드
state: str # CSRF state 토큰
class FacebookTokenResponse(BaseModel):
"""Facebook 토큰 교환 응답"""
access_token: str # 액세스 토큰
token_type: str # Bearer
expires_in: int # 만료 시간 (초)
class FacebookUserInfo(BaseModel):
"""Facebook 사용자 정보"""
id: str # Facebook 사용자 ID
name: str # 이름
email: Optional[str] # 이메일 (선택)
picture: Optional[dict] # 프로필 사진 (선택)
class FacebookPageInfo(BaseModel):
"""Facebook 페이지 정보"""
id: str # 페이지 ID
name: str # 페이지 이름
access_token: str # 페이지 액세스 토큰
category: Optional[str] # 카테고리
class FacebookAccountResponse(BaseModel):
"""Facebook 연동 완료 응답"""
success: bool
message: str
account_id: int # SocialAccount.id
platform_user_id: str # Facebook 사용자 ID
platform_username: str # Facebook 사용자 이름
pages: Optional[list[FacebookPageInfo]] # 관리 가능한 페이지 목록
```
---
### Phase 4: 의존성 정의
#### Step 4.1: app/sns/dependency.py - SNS 전용 Depends 구현
- **파일**: `app/sns/dependency.py`
- **역할**: SNS 모듈에서만 사용하는 FastAPI 의존성
**정의 내용**:
```python
async def get_facebook_oauth_client() -> FacebookOAuthClient:
"""FacebookOAuthClient 인스턴스를 제공하는 의존성"""
async def get_facebook_social_account(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SocialAccount:
"""현재 사용자의 활성 Facebook 소셜 계정을 조회하는 의존성"""
# SocialAccount에서 platform=facebook, is_active=True, is_deleted=False 조회
# 없으면 SocialAccountNotFoundError 발생
async def validate_oauth_state(state: str) -> str:
"""OAuth state 토큰 유효성 검증 의존성"""
# Redis에서 state 조회 및 검증
# 유효하지 않으면 예외 발생
```
---
### Phase 5: 서비스 레이어 구현
#### Step 5.1: app/sns/services/facebook.py - 비즈니스 로직 구현
- **파일**: `app/sns/services/facebook.py`
- **역할**: 엔드포인트와 OAuth 클라이언트 사이의 비즈니스 로직
- **핵심 원칙**: 모든 Facebook API 호출은 `FacebookOAuthClient` 클래스를 통해서만 수행
**서비스 함수 구조**:
```python
class FacebookService:
def __init__(self):
self.oauth_client = FacebookOAuthClient()
async def start_connect(self, user_uuid: str) -> FacebookConnectResponse:
"""Facebook 연동 시작"""
# 1. CSRF state 토큰 생성 (secrets.token_urlsafe)
# 2. Redis에 state:user_uuid 매핑 저장 (TTL: OAUTH_STATE_TTL_SECONDS)
# 3. oauth_client.get_authorization_url(state) 호출
# 4. FacebookConnectResponse 반환
async def handle_callback(
self, code: str, state: str, session: AsyncSession
) -> FacebookAccountResponse:
"""OAuth 콜백 처리"""
# 1. Redis에서 state로 user_uuid 조회 및 검증
# 2. oauth_client.get_access_token(code) → 단기 토큰 획득
# 3. oauth_client.exchange_long_lived_token() → 장기 토큰 교환
# 4. oauth_client.get_user_info() → 사용자 정보 조회
# 5. oauth_client.get_user_pages() → 페이지 목록 조회
# 6. SocialAccount 생성 또는 업데이트 (DB 저장)
# - platform: facebook
# - access_token: 장기 토큰
# - platform_user_id: Facebook 사용자 ID
# - platform_username: Facebook 사용자 이름
# - platform_data: {pages: [...], picture_url: "..."}
# - token_expires_at: 현재시간 + expires_in
# - scope: 요청한 scope 문자열
# 7. FacebookAccountResponse 반환
async def disconnect(
self, user_uuid: str, session: AsyncSession
) -> None:
"""Facebook 연동 해제"""
# SocialAccount 소프트 삭제 (is_deleted=True, is_active=False)
facebook_service = FacebookService()
```
**필수 사항**:
- 모든 주요 단계에서 `logger.debug()` 호출
- 주요 함수 호출 부분에 한글 주석 추가
- 에러 발생 시 `logger.error()` 로 상세 에러 정보 기록
---
### Phase 6: 라우터 구현
#### Step 6.1: app/sns/api/routers/v1/oauth.py - 엔드포인트 구현
- **파일**: `app/sns/api/routers/v1/oauth.py`
- **prefix**: `/sns` (항목 1번 요구사항 - sns 프리픽스 필수)
- **tags**: `["SNS OAuth"]`
**엔드포인트 구조**:
```python
router = APIRouter(prefix="/sns", tags=["SNS OAuth"])
@router.get("/facebook/connect")
async def facebook_connect(
current_user: User = Depends(get_current_user),
) -> FacebookConnectResponse:
"""Facebook OAuth 연동 시작"""
# facebook_service.start_connect() 호출
@router.get("/facebook/callback")
async def facebook_callback(
code: str | None = Query(None),
state: str | None = Query(None),
error: str | None = Query(None),
error_description: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> RedirectResponse:
"""Facebook OAuth 콜백 처리"""
# 에러/취소 처리
# facebook_service.handle_callback() 호출
# 성공/실패에 따라 프론트엔드로 리다이렉트
@router.delete("/facebook/disconnect")
async def facebook_disconnect(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Facebook 계정 연동 해제"""
# facebook_service.disconnect() 호출
```
---
### Phase 7: 모델 검토 및 수정
#### Step 7.1: SNSUploadTask 모델 필드 검토
- **파일**: `app/sns/models.py`
- **검토 항목**: Facebook 토큰 정보 저장을 위한 추가 필드 필요 여부
**분석 결과**:
- `SNSUploadTask`는 업로드 작업 관리용 모델이며, 토큰 저장 역할이 아님
- Facebook 토큰 정보는 기존 `SocialAccount` 모델에 이미 적절한 필드가 존재:
- `access_token` (Text): 장기 토큰 저장
- `refresh_token` (Text, nullable): Facebook은 refresh_token 미지원이므로 NULL
- `token_expires_at` (DateTime): 장기 토큰 만료 시간
- `platform_data` (JSON): 페이지 토큰, 페이지 ID 등 추가 정보
- `scope` (Text): OAuth scope 저장
- `SNSUploadTask`에 Facebook 업로드 시 필요한 필드 추가 검토:
- `platform` 필드 추가 (String(20)): 어떤 플랫폼에 업로드하는지 구분 (현재 Instagram 전용 구조)
- `platform_post_id` 필드 추가 (String(255), nullable): 업로드 후 플랫폼에서 반환한 게시물 ID
- `platform_post_url` 필드 추가 (String(2048), nullable): 업로드 후 게시물 URL
---
### Phase 8: main.py 라우터 등록 및 Scalar 문서 업데이트
#### Step 8.1: main.py 수정
- **파일**: `main.py`
**변경 내용**:
1. **라우터 import 추가**:
```python
from app.sns.api.routers.v1.oauth import router as sns_oauth_router
```
2. **라우터 등록**:
```python
app.include_router(sns_oauth_router) # SNS OAuth 라우터 (Facebook)
```
- 주의: oauth.py 내부 router에 이미 `/sns` prefix가 있으므로 main.py에서는 prefix 추가 불필요
3. **tags_metadata 업데이트** - SNS 태그 설명에 Facebook 추가:
```python
{
"name": "SNS OAuth",
"description": """SNS OAuth API - Facebook 계정 연동
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## Facebook OAuth 연동 흐름
1. `GET /sns/facebook/connect` - Facebook OAuth 인증 URL 획득
2. 사용자를 auth_url로 리다이렉트 → Facebook 로그인 및 권한 승인
3. Facebook에서 `/sns/facebook/callback`으로 인가 코드 전달
4. 서버에서 토큰 교환, 장기 토큰 발급, 사용자 정보 조회
5. 연동 완료 후 프론트엔드로 리다이렉트
## 계정 관리
- `DELETE /sns/facebook/disconnect` - Facebook 계정 연동 해제
""",
},
```
4. **공개 엔드포인트 추가** (인증 불필요):
```python
public_endpoints = [
...
"/sns/facebook/callback", # Facebook OAuth 콜백
]
```
5. **기존 SNS 태그 설명 업데이트**:
- SNS 태그 description에 Facebook 업로드 관련 엔드포인트 추가
---
## 4. 작업 순서 (실행 순서)
| 순서 | Phase | 작업 내용 | 대상 파일 | 의존성 |
|------|-------|----------|----------|--------|
| 1 | Phase 1 | config.py FacebookSettings 클래스 신설 + .env 변수 추가 | `config.py`, `.env` | 없음 |
| 2 | Phase 3 | Facebook 스키마 정의 | `app/sns/schemas/facebook_schema.py` | 없음 |
| 3 | Phase 2 | FacebookOAuthClient 클래스 구현 | `app/utils/facebook_oauth.py` | Step 1 (config) |
| 4 | Phase 4 | SNS 전용 Depends 구현 | `app/sns/dependency.py` | Step 3 |
| 5 | Phase 5 | Facebook 서비스 레이어 구현 | `app/sns/services/facebook.py` | Step 2, 3, 4 |
| 6 | Phase 6 | OAuth 라우터 엔드포인트 구현 | `app/sns/api/routers/v1/oauth.py` | Step 2, 5 |
| 7 | Phase 7 | SNSUploadTask 모델 필드 추가 | `app/sns/models.py` | Step 1~6 완료 후 검토 |
| 8 | Phase 8 | main.py 라우터 등록 및 Scalar 문서 | `main.py` | Step 6 |
| 9 | - | 코드리뷰 수행 (`/review`) | 전체 변경 파일 | Step 1~8 전체 |
| 10 | - | 코드리뷰 결과 기반 개선 | 해당 파일 | Step 9 |
---
## 5. 품질 기준 (전 단계 공통)
### 5.1 로깅 기준 (항목 7)
- **모든 엔드포인트 진입점**: `logger.info()` 로 요청 시작 기록
- **외부 API 호출 전/후**: `logger.debug()` 로 URL, 파라미터, 응답 상태 기록
- **중요 변수 할당**: `logger.debug()` 로 변수값 확인
- **에러 발생 시**: `logger.error()` 로 상세 에러 정보 기록
- **로거 패턴**: `from app.utils.logger import get_logger; logger = get_logger(__name__)`
### 5.2 주석 기준 (항목 8)
- 각 메서드의 주요 단계별 한글 주석 (예: `# 인가 코드를 액세스 토큰으로 교환`)
- 외부 API 호출 시 호출 대상 명시 (예: `# Facebook Graph API - 사용자 정보 조회`)
- 복잡한 분기 로직에 판단 기준 설명
### 5.3 코드 품질 기준
- 타입 힌트 필수
- async/await 패턴 준수
- Pydantic v2 스키마 사용
- 서비스 레이어 패턴 (router → service → oauth_client)
---
## 6. Scalar 문서 설정 (항목 9)
### 변경 파일: `main.py`
- `tags_metadata`에 "SNS OAuth" 태그 추가
- `custom_openapi()` 함수의 `public_endpoints`에 Facebook 콜백 경로 추가
- 기존 "SNS" 태그 description에 Facebook 업로드 안내 추가 (향후 확장)
---
## 7. 코드리뷰 수행 (항목 10, 11)
### 리뷰 범위
모든 구현 완료 후 1회 코드리뷰 수행:
1. **설계 검증**
- 레이어 분리 적정성 (router → service → oauth_client)
- 의존성 방향 확인 (순환 의존 없음)
- 예외 처리 체계 일관성
2. **코드 정의 검증**
- 타입 힌트 누락 확인
- async/await 적정 사용
- 로거 사용 패턴 일관성
- 주석 존재 여부
3. **보안 검증**
- CSRF state 토큰 검증 로직
- 토큰 노출 방지 (로그에 토큰 전체 미출력)
- redirect_uri 고정 검증
4. **기능 검증**
- OAuth 흐름 완전성
- 에러 케이스 처리 (취소, 코드 만료, 토큰 실패)
- DB 저장 정합성
### 리뷰 결과 처리 (항목 11)
- 설계적 오류: 구조 변경 및 수정
- 코드 정의 오류: 즉시 수정 반영
---
## 8. 검수 1차 - 작업 계획 완전성 검증
### 검수 항목별 준수 여부
| # | 요구사항 | 준수 여부 | 근거 |
|---|---------|----------|------|
| 1 | oauth.py 라우터에 sns 프리픽스 | **O** | `router = APIRouter(prefix="/sns")` - Phase 6 |
| 2 | utils/facebook_oauth.py에 단일 클래스, 이름에 oauth 포함 | **O** | `FacebookOAuthClient` 클래스 - Phase 2 |
| 3 | 비즈니스 로직은 facebook.py, facebook_oauth.py 클래스 사용 | **O** | `FacebookService``FacebookOAuthClient`를 호출 - Phase 5 |
| 4 | SNSUploadTask 모델 참조 후 필요 필드 추가 | **O** | Phase 7에서 platform, platform_post_id, platform_post_url 추가 검토 |
| 5 | SNS 전용 Depends는 sns/dependency.py | **O** | Phase 4에서 구현 |
| 6 | 스키마는 sns/schemas에 정의 | **O** | `facebook_schema.py` - Phase 3 |
| 7 | 중요 입력/호출/변수 logger.debug 출력 | **O** | 품질 기준 5.1 적용 |
| 8 | 중요 함수 호출 주석 | **O** | 품질 기준 5.2 적용 |
| 9 | Scalar 문서 설정 업데이트 | **O** | Phase 8에서 tags_metadata, public_endpoints 수정 |
| 10 | 1회 코드리뷰 수행 | **O** | Step 9에서 `/review` 수행 |
| 11 | 리뷰 후 설계적/코드 정의 오류 개선 | **O** | Step 10에서 수정 반영 |
### 1차 검수 발견 사항
**발견 1: redirect_uri 불일치 위험**
- config.py의 `SocialOAuthSettings` 내 기존 주석은 `/social/facebook/callback`으로 되어 있으나, 본 계획에서는 `/sns/facebook/callback`으로 설정
- **해결**: `FacebookSettings` 독립 클래스에서 redirect_uri를 `/sns/facebook/callback`으로 명확히 설정하고, `SocialOAuthSettings` 내 Facebook 주석 코드는 삭제
**발견 2: Redis 의존성 미확인**
- OAuth state 토큰 저장에 Redis 사용 예정이나, Redis 클라이언트 획득 방법 미명시
- **해결**: `app/database/session.py`의 Redis 연결 활용 또는 `dependency.py`에 Redis 의존성 추가 명시
**발견 3: 기존 SNS 라우터와의 prefix 충돌 가능성**
- 기존 `app/sns/api/routers/v1/sns.py`에 이미 `prefix="/sns"`가 설정되어 있음
- 새로운 `oauth.py`에도 `prefix="/sns"` 설정 시 main.py에서 중복 prefix 발생 가능
- **해결**: main.py에서 기존 sns_router는 prefix 없이 등록 (`app.include_router(sns_router)`), 새 oauth_router도 prefix 없이 등록하되 router 내부에 `/sns` prefix 유지
**발견 4: schemas 디렉토리 __init__.py 확인 필요**
- `app/sns/schemas/` 디렉토리에 `__init__.py`가 있는지 확인하여 import 가능하도록 보장
---
## 9. 검수 2차 - 설계 개선 검토
### 설계 관점 검토
**검토 1: 레이어 분리 적정성** - **적정**
```
Router (oauth.py)
↓ 호출
Service (facebook.py - FacebookService)
↓ 호출
OAuth Client (facebook_oauth.py - FacebookOAuthClient)
↓ HTTP 요청
Facebook Graph API
```
- 각 레이어의 책임이 명확히 분리됨
- OAuth 클라이언트는 HTTP 통신만, 서비스는 비즈니스 로직만, 라우터는 HTTP 요청/응답만 담당
**검토 2: 기존 social 모듈과의 관계** - **개선 필요 없음**
- `app/social/` 모듈은 YouTube OAuth 및 범용 업로드 담당
- `app/sns/` 모듈은 Instagram/Facebook 등 SNS 특화 기능 담당
- 역할이 명확히 분리되어 있으므로 현행 구조 유지
**검토 3: 토큰 갱신 전략** - **보완 필요**
- Facebook은 refresh_token을 미지원하며, 장기 토큰도 약 60일 만료
- **보완**: `FacebookOAuthClient`에 토큰 만료 확인 유틸리티 메서드 추가
```python
def is_token_expired(self, token_expires_at: datetime) -> bool:
"""토큰 만료 여부 확인 (만료 7일 전부터 True 반환)"""
```
- 서비스 레이어에서 API 호출 전 토큰 만료 확인 → 만료 시 재연동 안내 예외 발생
**검토 4: 페이지 토큰 관리** - **적정**
- 페이지 토큰은 `SocialAccount.platform_data` JSON 필드에 저장
- 이 방식은 기존 YouTube의 channel_id 저장 패턴과 일관됨
**검토 5: scope 범위** - **해결 완료**
- 기본 scope: `public_profile, email` (Facebook 앱 리뷰 없이 사용 가능)
- 페이지 관리 scope: `pages_show_list, pages_read_engagement, pages_manage_posts` (앱 리뷰 필요)
- 비디오 업로드 scope: `publish_video` (향후 확장 시)
- **해결**: `FacebookSettings.FACEBOOK_OAUTH_SCOPE` 필드에서 .env 변수로 관리
- 기본값: `public_profile,email,pages_show_list,pages_read_engagement,pages_manage_posts`
- 향후 확장 시 .env 파일에서 scope 변경만으로 대응 가능
**검토 6: 에러 코드 체계** - **적정**
- Facebook OAuth 예외 클래스가 기존 Kakao 패턴과 동일 구조
- HTTP 상태 코드 + 내부 에러 코드 + 메시지 3단계 체계
### 2차 검수 발견 사항
**발견 1: state 토큰 저장소 구현 세부사항 보완**
- Redis 키 패턴 명확화 필요: `facebook_oauth_state:{state}` → value: `user_uuid`
- TTL 설정: `social_oauth_settings.OAUTH_STATE_TTL_SECONDS` (기본 300초)
- **반영**: Phase 5의 `start_connect()` 구현 시 Redis 키 패턴 명시
**발견 2: 기존 SocialAccount 중복 체크 로직**
- 동일 Facebook 사용자가 재연동할 경우, 기존 레코드를 업데이트해야 함
- **반영**: `handle_callback()`에서 `(user_uuid, platform, platform_user_id)` 기준으로 UPSERT 로직 구현
**발견 3: 향후 확장성 확보**
- 현재 Facebook 전용이지만 TikTok 등 추가 시 패턴 재사용 가능한 구조
- `dependency.py``get_facebook_social_account()`는 플랫폼 파라미터화하여 범용화 가능
- **판단**: 현재 단계에서는 Facebook 전용으로 유지 (YAGNI 원칙)
---
## 10. 최종 작업 실행 순서 (확정)
```
1. config.py 수정 (FacebookSettings 독립 클래스 신설 + SocialOAuthSettings 내 Facebook 주석 삭제)
2. .env 파일에 FACEBOOK_ 환경변수 추가
3. app/sns/schemas/facebook_schema.py 생성 (스키마 정의)
4. app/utils/facebook_oauth.py 생성 (FacebookOAuthClient 클래스 - facebook_settings 참조)
5. app/sns/dependency.py 구현 (SNS 전용 Depends)
6. app/sns/services/facebook.py 구현 (비즈니스 로직)
7. app/sns/api/routers/v1/oauth.py 구현 (엔드포인트)
8. app/sns/models.py 검토 및 필드 추가
9. main.py 수정 (라우터 등록 + Scalar 문서 업데이트)
10. 코드리뷰 수행 (/review)
11. 코드리뷰 결과 기반 개선 수행
```
---
## 부록: 참조 소스
### 기존 코드 패턴 참조 파일
| 패턴 | 참조 파일 | 설명 |
|------|----------|------|
| OAuth 클래스 | `app/user/services/kakao.py` | KakaoOAuthClient 구조 |
| HTTP 클라이언트 | `app/utils/instagram.py` | httpx.AsyncClient 패턴 |
| SNS 라우터 | `app/sns/api/routers/v1/sns.py` | prefix, 예외 처리 패턴 |
| Social OAuth 라우터 | `app/social/api/routers/v1/oauth.py` | 콜백, 리다이렉트 패턴 |
| 모델 | `app/user/models.py` | SocialAccount, Platform enum |
| 스키마 | `app/sns/schemas/sns_schema.py` | Pydantic v2 패턴 |
| 설정 (독립 클래스) | `config.py` | KakaoSettings 패턴 → FacebookSettings |
| 설정 (OAuth 공통) | `config.py` | SocialOAuthSettings (state TTL, 프론트엔드 URL 등) |
| 로거 | `app/utils/logger.py` | get_logger 패턴 |

View File

@ -314,10 +314,7 @@ def add_exception_handlers(app: FastAPI):
@app.exception_handler(DashboardException) @app.exception_handler(DashboardException)
def dashboard_exception_handler(request: Request, exc: DashboardException) -> Response: def dashboard_exception_handler(request: Request, exc: DashboardException) -> Response:
if exc.status_code < 500: logger.debug(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
logger.warning(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
else:
logger.error(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content={ content={

View File

@ -4,22 +4,43 @@ Dashboard API 라우터
YouTube Analytics 기반 대시보드 통계를 제공합니다. YouTube Analytics 기반 대시보드 통계를 제공합니다.
""" """
import json
import logging import logging
from datetime import date, datetime, timedelta
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dashboard.utils.redis_cache import delete_cache_pattern from app.dashboard.exceptions import (
from app.dashboard.schemas import ( YouTubeAccountNotConnectedError,
CacheDeleteResponse, YouTubeAccountNotFoundError,
ConnectedAccountsResponse, YouTubeAccountSelectionRequiredError,
DashboardResponse, YouTubeTokenExpiredError,
)
from app.dashboard.schemas import (
AudienceData,
CacheDeleteResponse,
ConnectedAccount,
ConnectedAccountsResponse,
ContentMetric,
DashboardResponse,
TopContent,
)
from app.dashboard.services import DataProcessor, YouTubeAnalyticsService
from app.dashboard.redis_cache import (
delete_cache,
delete_cache_pattern,
get_cache,
set_cache,
) )
from app.dashboard.services import DashboardService
from app.database.session import get_session from app.database.session import get_session
from app.dashboard.models import Dashboard
from app.social.exceptions import TokenExpiredError
from app.social.services import SocialAccountService
from app.user.dependencies.auth import get_current_user from app.user.dependencies.auth import get_current_user
from app.user.models import User from app.user.models import SocialAccount, User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -40,8 +61,41 @@ async def get_connected_accounts(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> ConnectedAccountsResponse: ) -> ConnectedAccountsResponse:
service = DashboardService() result = await session.execute(
connected = await service.get_connected_accounts(current_user, session) select(SocialAccount).where(
SocialAccount.user_uuid == current_user.user_uuid,
SocialAccount.platform == "youtube",
SocialAccount.is_active == True, # noqa: E712
)
)
accounts_raw = result.scalars().all()
# platform_user_id 기준
seen_platform_ids: set[str] = set()
connected = []
for acc in sorted(
accounts_raw, key=lambda a: a.connected_at or datetime.min, reverse=True
):
if acc.platform_user_id in seen_platform_ids:
continue
seen_platform_ids.add(acc.platform_user_id)
data = acc.platform_data if isinstance(acc.platform_data, dict) else {}
connected.append(
ConnectedAccount(
id=acc.id,
platform=acc.platform,
platform_username=acc.platform_username,
platform_user_id=acc.platform_user_id,
channel_title=data.get("channel_title"),
connected_at=acc.connected_at,
is_active=acc.is_active,
)
)
logger.info(
f"[ACCOUNTS] YouTube 계정 목록 조회 - "
f"user_uuid={current_user.user_uuid}, count={len(connected)}"
)
return ConnectedAccountsResponse(accounts=connected) return ConnectedAccountsResponse(accounts=connected)
@ -88,8 +142,328 @@ async def get_dashboard_stats(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> DashboardResponse: ) -> DashboardResponse:
service = DashboardService() """
return await service.get_stats(mode, platform_user_id, current_user, session) 대시보드 통계 조회
Args:
mode: 조회 모드 (day: 최근 30, month: 최근 12개월)
platform_user_id: 사용할 YouTube 채널 ID (여러 계정 연결 필수, 재연동해도 불변)
current_user: 현재 인증된 사용자
session: 데이터베이스 세션
Returns:
DashboardResponse: 대시보드 통계 데이터
Raises:
YouTubeAccountNotConnectedError: YouTube 계정이 연동되어 있지 않음
YouTubeAccountSelectionRequiredError: 여러 계정이 연결되어 있으나 계정 미선택
YouTubeAccountNotFoundError: 지정한 계정을 찾을 없음
YouTubeTokenExpiredError: YouTube 토큰 만료 (재연동 필요)
YouTubeAPIError: YouTube Analytics API 호출 실패
"""
logger.info(
f"[DASHBOARD] 통계 조회 시작 - "
f"user_uuid={current_user.user_uuid}, mode={mode}, platform_user_id={platform_user_id}"
)
# 1. 모드별 날짜 자동 계산
today = date.today()
if mode == "day":
# 48시간 지연 적용: 오늘 기준 -2일을 end로 사용
# ex) 오늘 2/20 → end=2/18, start=1/20
end_dt = today - timedelta(days=2)
kpi_end_dt = end_dt
start_dt = end_dt - timedelta(days=29)
# 이전 30일 (YouTube API day_previous와 동일 기준)
prev_start_dt = start_dt - timedelta(days=30)
prev_kpi_end_dt = kpi_end_dt - timedelta(days=30)
period_desc = "최근 30일"
else: # mode == "month"
# 월별 차트: dimensions=month API는 YYYY-MM-01 형식 필요
# ex) 오늘 2/24 → end=2026-02-01, start=2025-03-01 → 2025-03 ~ 2026-02 (12개월)
end_dt = today.replace(day=1)
# KPI 등 집계형 API: 48시간 지연 적용하여 현재 월 전체 데이터 포함
kpi_end_dt = today - timedelta(days=2)
start_month = end_dt.month - 11
if start_month <= 0:
start_month += 12
start_year = end_dt.year - 1
else:
start_year = end_dt.year
start_dt = date(start_year, start_month, 1)
# 이전 12개월 (YouTube API previous와 동일 기준 — 1년 전)
prev_start_dt = start_dt.replace(year=start_dt.year - 1)
try:
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1)
except ValueError: # 윤년 2/29 → 이전 연도 2/28
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1, day=28)
period_desc = "최근 12개월"
start_date = start_dt.strftime("%Y-%m-%d")
end_date = end_dt.strftime("%Y-%m-%d")
kpi_end_date = kpi_end_dt.strftime("%Y-%m-%d")
logger.debug(
f"[1] 날짜 계산 완료 - period={period_desc}, start={start_date}, end={end_date}"
)
# 2. YouTube 계정 연동 확인
result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == current_user.user_uuid,
SocialAccount.platform == "youtube",
SocialAccount.is_active == True, # noqa: E712
)
)
social_accounts_raw = result.scalars().all()
# platform_user_id 기준으로 중복 제거 (가장 최근 연동 계정 우선)
seen_platform_ids_stats: set[str] = set()
social_accounts = []
for acc in sorted(
social_accounts_raw, key=lambda a: a.connected_at or datetime.min, reverse=True
):
if acc.platform_user_id not in seen_platform_ids_stats:
seen_platform_ids_stats.add(acc.platform_user_id)
social_accounts.append(acc)
if not social_accounts:
logger.warning(
f"[NO YOUTUBE ACCOUNT] YouTube 계정 미연동 - "
f"user_uuid={current_user.user_uuid}"
)
raise YouTubeAccountNotConnectedError()
if platform_user_id is not None:
matched = [a for a in social_accounts if a.platform_user_id == platform_user_id]
if not matched:
logger.warning(
f"[ACCOUNT NOT FOUND] 지정 계정 없음 - "
f"user_uuid={current_user.user_uuid}, platform_user_id={platform_user_id}"
)
raise YouTubeAccountNotFoundError()
social_account = matched[0]
elif len(social_accounts) == 1:
social_account = social_accounts[0]
else:
logger.warning(
f"[MULTI ACCOUNT] 계정 선택 필요 - "
f"user_uuid={current_user.user_uuid}, count={len(social_accounts)}"
)
raise YouTubeAccountSelectionRequiredError()
logger.debug(
f"[2] YouTube 계정 확인 완료 - platform_user_id={social_account.platform_user_id}"
)
# 3. 기간 내 업로드 영상 수 조회
count_result = await session.execute(
select(func.count())
.select_from(Dashboard)
.where(
Dashboard.user_uuid == current_user.user_uuid,
Dashboard.platform == "youtube",
Dashboard.platform_user_id == social_account.platform_user_id,
Dashboard.uploaded_at >= start_dt,
Dashboard.uploaded_at < today + timedelta(days=1),
)
)
period_video_count = count_result.scalar() or 0
# 이전 기간 업로드 영상 수 조회 (trend 계산용)
prev_count_result = await session.execute(
select(func.count())
.select_from(Dashboard)
.where(
Dashboard.user_uuid == current_user.user_uuid,
Dashboard.platform == "youtube",
Dashboard.platform_user_id == social_account.platform_user_id,
Dashboard.uploaded_at >= prev_start_dt,
Dashboard.uploaded_at <= prev_kpi_end_dt,
)
)
prev_period_video_count = prev_count_result.scalar() or 0
logger.debug(
f"[3] 기간 내 업로드 영상 수 - current={period_video_count}, prev={prev_period_video_count}"
)
# 4. Redis 캐시 조회
# platform_user_id 기준 캐시 키: 재연동해도 채널 ID는 불변 → 캐시 유지됨
cache_key = f"dashboard:{current_user.user_uuid}:{social_account.platform_user_id}:{mode}"
cached_raw = await get_cache(cache_key)
if cached_raw:
try:
payload = json.loads(cached_raw)
logger.info(f"[CACHE HIT] 캐시 반환 - user_uuid={current_user.user_uuid}")
response = DashboardResponse.model_validate(payload["response"])
for metric in response.content_metrics:
if metric.id == "uploaded-videos":
metric.value = float(period_video_count)
video_trend = float(period_video_count - prev_period_video_count)
metric.trend = video_trend
metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-")
break
return response
except (json.JSONDecodeError, KeyError):
logger.warning(f"[CACHE PARSE ERROR] 포맷 오류, 무시 - key={cache_key}")
logger.debug("[4] 캐시 MISS - YouTube API 호출 필요")
# 5. 최근 30개 업로드 영상 조회 (Analytics API 전달용)
# YouTube Analytics API 제약사항:
# - 영상 개수: 20~30개 권장 (최대 50개, 그 이상은 응답 지연 발생)
# - URL 길이: 2000자 제한 (video ID 11자 × 30개 = 330자로 안전)
result = await session.execute(
select(
Dashboard.platform_video_id,
Dashboard.title,
Dashboard.uploaded_at,
)
.where(
Dashboard.user_uuid == current_user.user_uuid,
Dashboard.platform == "youtube",
Dashboard.platform_user_id == social_account.platform_user_id,
)
.order_by(Dashboard.uploaded_at.desc())
.limit(30)
)
rows = result.all()
logger.debug(f"[5] 영상 조회 완료 - count={len(rows)}")
# 6. video_ids + 메타데이터 조회용 dict 구성
video_ids = []
video_lookup: dict[str, tuple[str, datetime]] = {} # {video_id: (title, uploaded_at)}
for row in rows:
platform_video_id, title, uploaded_at = row
video_ids.append(platform_video_id)
video_lookup[platform_video_id] = (title, uploaded_at)
logger.debug(
f"[6] 영상 메타데이터 구성 완료 - count={len(video_ids)}, sample={video_ids[:3]}"
)
# 6.1 업로드 영상 없음 → YouTube API 호출 없이 빈 응답 반환
if not video_ids:
logger.info(
f"[DASHBOARD] 업로드 영상 없음, 빈 응답 반환 - "
f"user_uuid={current_user.user_uuid}"
)
return DashboardResponse(
content_metrics=[
ContentMetric(id="total-views", label="조회수", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="total-watch-time", label="시청시간", value=0.0, unit="hours", trend=0.0, trend_direction="-"),
ContentMetric(id="avg-view-duration", label="평균 시청시간", value=0.0, unit="minutes", trend=0.0, trend_direction="-"),
ContentMetric(id="new-subscribers", label="신규 구독자", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="likes", label="좋아요", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="comments", label="댓글", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="shares", label="공유", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="uploaded-videos", label="업로드 영상", value=0.0, unit="count", trend=0.0, trend_direction="-"),
],
monthly_data=[],
daily_data=[],
top_content=[],
audience_data=AudienceData(age_groups=[], gender={"male": 0, "female": 0}, top_regions=[]),
has_uploads=False,
)
# 7. 토큰 유효성 확인 및 자동 갱신 (만료 10분 전 갱신)
try:
access_token = await SocialAccountService().ensure_valid_token(
social_account, session
)
except TokenExpiredError:
logger.warning(
f"[TOKEN EXPIRED] 재연동 필요 - user_uuid={current_user.user_uuid}"
)
raise YouTubeTokenExpiredError()
logger.debug("[7] 토큰 유효성 확인 완료")
# 8. YouTube Analytics API 호출 (7개 병렬)
youtube_service = YouTubeAnalyticsService()
raw_data = await youtube_service.fetch_all_metrics(
video_ids=video_ids,
start_date=start_date,
end_date=end_date,
kpi_end_date=kpi_end_date,
access_token=access_token,
mode=mode,
)
logger.debug("[8] YouTube Analytics API 호출 완료")
# 9. TopContent 조립 (Analytics top_videos + DB lookup)
processor = DataProcessor()
top_content_rows = raw_data.get("top_videos", {}).get("rows", [])
top_content: list[TopContent] = []
for row in top_content_rows[:4]:
if len(row) < 4:
continue
video_id, views, likes, comments = row[0], row[1], row[2], row[3]
meta = video_lookup.get(video_id)
if not meta:
continue
title, uploaded_at = meta
engagement_rate = ((likes + comments) / views * 100) if views > 0 else 0
top_content.append(
TopContent(
id=video_id,
title=title,
thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg",
platform="youtube",
views=int(views),
engagement=f"{engagement_rate:.1f}%",
date=uploaded_at.strftime("%Y.%m.%d"),
)
)
logger.debug(f"[9] TopContent 조립 완료 - count={len(top_content)}")
# 10. 데이터 가공 (period_video_count=0 — API 무관 DB 집계값, 캐시에 포함하지 않음)
dashboard_data = processor.process(
raw_data, top_content, 0, mode=mode, end_date=end_date
)
logger.debug("[10] 데이터 가공 완료")
# 11. Redis 캐싱 (TTL: 12시간)
# YouTube Analytics는 하루 1회 갱신 (PT 자정, 한국 시간 오후 5~8시)
# 48시간 지연된 데이터이므로 12시간 캐싱으로 API 호출 최소화
# period_video_count는 캐시에 포함하지 않음 (DB 직접 집계, API 미사용)
cache_payload = json.dumps(
{"response": json.loads(dashboard_data.model_dump_json())}
)
cache_success = await set_cache(
cache_key,
cache_payload,
ttl=43200, # 12시간
)
if cache_success:
logger.debug(f"[CACHE SET] 캐시 저장 성공 - key={cache_key}")
else:
logger.warning(f"[CACHE SET] 캐시 저장 실패 - key={cache_key}")
# 12. 업로드 영상 수 및 trend 주입 (캐시 저장 후 — 항상 DB에서 직접 집계)
for metric in dashboard_data.content_metrics:
if metric.id == "uploaded-videos":
metric.value = float(period_video_count)
video_trend = float(period_video_count - prev_period_video_count)
metric.trend = video_trend
metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-")
break
logger.info(
f"[DASHBOARD] 통계 조회 완료 - "
f"user_uuid={current_user.user_uuid}, "
f"mode={mode}, period={period_desc}, videos={len(video_ids)}"
)
return dashboard_data
@router.delete( @router.delete(
@ -109,7 +483,7 @@ async def get_dashboard_stats(
`dashboard:{user_uuid}:{platform_user_id}:{mode}` (mode: day 또는 month) `dashboard:{user_uuid}:{platform_user_id}:{mode}` (mode: day 또는 month)
## 파라미터 ## 파라미터
- `user_uuid`: 삭제할 사용자 UUID (필수) - `user_uuid`: 특정 사용자 캐시만 삭제. 미입력 전체 삭제
- `mode`: day / month / all (기본값: all) - `mode`: day / month / all (기본값: all)
""", """,
) )
@ -118,16 +492,33 @@ async def delete_dashboard_cache(
default="all", default="all",
description="삭제할 캐시 모드: day, month, all(기본값, 모두 삭제)", description="삭제할 캐시 모드: day, month, all(기본값, 모두 삭제)",
), ),
user_uuid: str = Query( user_uuid: str | None = Query(
description="대상 사용자 UUID", default=None,
description="대상 사용자 UUID. 미입력 시 전체 사용자 캐시 삭제",
), ),
) -> CacheDeleteResponse: ) -> CacheDeleteResponse:
if mode == "all": """
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*") 대시보드 캐시 삭제
message = f"전체 캐시 삭제 완료 ({deleted}개)"
Args:
mode: 삭제할 캐시 모드 (day / month / all)
user_uuid: 대상 사용자 UUID (없으면 전체 삭제)
Returns:
CacheDeleteResponse: 삭제된 캐시 개수 메시지
"""
if user_uuid:
if mode == "all":
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*")
message = f"전체 캐시 삭제 완료 ({deleted}개)"
else:
cache_key = f"dashboard:{user_uuid}:{mode}"
success = await delete_cache(cache_key)
deleted = 1 if success else 0
message = f"{mode} 캐시 삭제 {'완료' if success else '실패 (키 없음)'}"
else: else:
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*:{mode}") deleted = await delete_cache_pattern("dashboard:*")
message = f"{mode} 캐시 삭제 완료 ({deleted}개)" message = f"전체 사용자 캐시 삭제 완료 ({deleted}개)"
logger.info( logger.info(
f"[CACHE DELETE] user_uuid={user_uuid or 'ALL'}, mode={mode}, deleted={deleted}" f"[CACHE DELETE] user_uuid={user_uuid or 'ALL'}, mode={mode}, deleted={deleted}"

View File

@ -113,7 +113,7 @@ class YouTubeAccountSelectionRequiredError(DashboardException):
def __init__(self): def __init__(self):
super().__init__( super().__init__(
message="연결된 YouTube 계정이 여러 개입니다. platform_user_id 파라미터로 사용할 계정을 선택해주세요.", message="연결된 YouTube 계정이 여러 개입니다. social_account_id 파라미터로 사용할 계정을 선택해주세요.",
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
code="YOUTUBE_ACCOUNT_SELECTION_REQUIRED", code="YOUTUBE_ACCOUNT_SELECTION_REQUIRED",
) )

View File

@ -197,6 +197,35 @@ class AudienceData(BaseModel):
) )
# class PlatformMetric(BaseModel):
# """플랫폼별 메트릭 (미사용 — platform_data 기능 미구현)"""
#
# id: str
# label: str
# value: str
# unit: Optional[str] = None
# trend: float
# trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
#
# model_config = ConfigDict(
# alias_generator=to_camel,
# populate_by_name=True,
# )
#
#
# class PlatformData(BaseModel):
# """플랫폼별 데이터 (미사용 — platform_data 기능 미구현)"""
#
# platform: Literal["youtube", "instagram"]
# display_name: str = Field(alias="displayName")
# metrics: list[PlatformMetric]
#
# model_config = ConfigDict(
# alias_generator=to_camel,
# populate_by_name=True,
# )
class DashboardResponse(BaseModel): class DashboardResponse(BaseModel):
"""대시보드 전체 응답 """대시보드 전체 응답
@ -226,6 +255,7 @@ class DashboardResponse(BaseModel):
top_content: list[TopContent] = Field(alias="topContent") top_content: list[TopContent] = Field(alias="topContent")
audience_data: AudienceData = Field(alias="audienceData") audience_data: AudienceData = Field(alias="audienceData")
has_uploads: bool = Field(default=True, alias="hasUploads") has_uploads: bool = Field(default=True, alias="hasUploads")
# platform_data: list[PlatformData] = Field(default=[], alias="platformData") # 미사용
model_config = ConfigDict( model_config = ConfigDict(
alias_generator=to_camel, alias_generator=to_camel,

View File

@ -4,12 +4,10 @@ Dashboard Services
YouTube Analytics API 연동 데이터 가공 서비스를 제공합니다. YouTube Analytics API 연동 데이터 가공 서비스를 제공합니다.
""" """
from app.dashboard.services.dashboard_service import DashboardService
from app.dashboard.services.data_processor import DataProcessor from app.dashboard.services.data_processor import DataProcessor
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
__all__ = [ __all__ = [
"DashboardService",
"YouTubeAnalyticsService", "YouTubeAnalyticsService",
"DataProcessor", "DataProcessor",
] ]

View File

@ -1,358 +0,0 @@
"""
Dashboard Service
대시보드 비즈니스 로직을 담당합니다.
"""
import json
import logging
from datetime import date, datetime, timedelta
from typing import Literal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dashboard.exceptions import (
YouTubeAccountNotConnectedError,
YouTubeAccountNotFoundError,
YouTubeAccountSelectionRequiredError,
YouTubeTokenExpiredError,
)
from app.dashboard.models import Dashboard
from app.dashboard.utils.redis_cache import get_cache, set_cache
from app.dashboard.schemas import (
AudienceData,
ConnectedAccount,
ContentMetric,
DashboardResponse,
TopContent,
)
from app.dashboard.services.data_processor import DataProcessor
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
from app.social.exceptions import TokenExpiredError
from app.social.services import SocialAccountService
from app.user.models import SocialAccount, User
logger = logging.getLogger(__name__)
class DashboardService:
async def get_connected_accounts(
self,
current_user: User,
session: AsyncSession,
) -> list[ConnectedAccount]:
result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == current_user.user_uuid,
SocialAccount.platform == "youtube",
SocialAccount.is_active == True, # noqa: E712
)
)
accounts_raw = result.scalars().all()
connected = []
for acc in accounts_raw:
data = acc.platform_data if isinstance(acc.platform_data, dict) else {}
connected.append(
ConnectedAccount(
id=acc.id,
platform=acc.platform,
platform_username=acc.platform_username,
platform_user_id=acc.platform_user_id,
channel_title=data.get("channel_title"),
connected_at=acc.connected_at,
is_active=acc.is_active,
)
)
logger.info(
f"[ACCOUNTS] YouTube 계정 목록 조회 - "
f"user_uuid={current_user.user_uuid}, count={len(connected)}"
)
return connected
def calculate_date_range(
self, mode: Literal["day", "month"]
) -> tuple[date, date, date, date, date, str]:
"""모드별 날짜 범위 계산. (start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc) 반환"""
today = date.today()
if mode == "day":
end_dt = today - timedelta(days=2)
kpi_end_dt = end_dt
start_dt = end_dt - timedelta(days=29)
prev_start_dt = start_dt - timedelta(days=30)
prev_kpi_end_dt = kpi_end_dt - timedelta(days=30)
period_desc = "최근 30일"
else:
end_dt = today.replace(day=1)
kpi_end_dt = today - timedelta(days=2)
start_month = end_dt.month - 11
if start_month <= 0:
start_month += 12
start_year = end_dt.year - 1
else:
start_year = end_dt.year
start_dt = date(start_year, start_month, 1)
prev_start_dt = start_dt.replace(year=start_dt.year - 1)
try:
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1)
except ValueError:
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1, day=28)
period_desc = "최근 12개월"
return start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc
async def resolve_social_account(
self,
current_user: User,
session: AsyncSession,
platform_user_id: str | None,
) -> SocialAccount:
result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == current_user.user_uuid,
SocialAccount.platform == "youtube",
SocialAccount.is_active == True, # noqa: E712
)
)
social_accounts_raw = result.scalars().all()
social_accounts = list(social_accounts_raw)
if not social_accounts:
raise YouTubeAccountNotConnectedError()
if platform_user_id is not None:
matched = [a for a in social_accounts if a.platform_user_id == platform_user_id]
if not matched:
raise YouTubeAccountNotFoundError()
return matched[0]
elif len(social_accounts) == 1:
return social_accounts[0]
else:
raise YouTubeAccountSelectionRequiredError()
async def get_video_counts(
self,
current_user: User,
session: AsyncSession,
social_account: SocialAccount,
start_dt: date,
prev_start_dt: date,
prev_kpi_end_dt: date,
) -> tuple[int, int]:
today = date.today()
count_result = await session.execute(
select(func.count())
.select_from(Dashboard)
.where(
Dashboard.user_uuid == current_user.user_uuid,
Dashboard.platform == "youtube",
Dashboard.platform_user_id == social_account.platform_user_id,
Dashboard.uploaded_at >= start_dt,
Dashboard.uploaded_at < today + timedelta(days=1),
)
)
period_video_count = count_result.scalar() or 0
prev_count_result = await session.execute(
select(func.count())
.select_from(Dashboard)
.where(
Dashboard.user_uuid == current_user.user_uuid,
Dashboard.platform == "youtube",
Dashboard.platform_user_id == social_account.platform_user_id,
Dashboard.uploaded_at >= prev_start_dt,
Dashboard.uploaded_at <= prev_kpi_end_dt,
)
)
prev_period_video_count = prev_count_result.scalar() or 0
return period_video_count, prev_period_video_count
async def get_video_ids(
self,
current_user: User,
session: AsyncSession,
social_account: SocialAccount,
) -> tuple[list[str], dict[str, tuple[str, datetime]]]:
result = await session.execute(
select(
Dashboard.platform_video_id,
Dashboard.title,
Dashboard.uploaded_at,
)
.where(
Dashboard.user_uuid == current_user.user_uuid,
Dashboard.platform == "youtube",
Dashboard.platform_user_id == social_account.platform_user_id,
)
.order_by(Dashboard.uploaded_at.desc())
.limit(30)
)
rows = result.all()
video_ids = []
video_lookup: dict[str, tuple[str, datetime]] = {}
for row in rows:
platform_video_id, title, uploaded_at = row
video_ids.append(platform_video_id)
video_lookup[platform_video_id] = (title, uploaded_at)
return video_ids, video_lookup
def build_empty_response(self) -> DashboardResponse:
return DashboardResponse(
content_metrics=[
ContentMetric(id="total-views", label="조회수", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="total-watch-time", label="시청시간", value=0.0, unit="hours", trend=0.0, trend_direction="-"),
ContentMetric(id="avg-view-duration", label="평균 시청시간", value=0.0, unit="minutes", trend=0.0, trend_direction="-"),
ContentMetric(id="new-subscribers", label="신규 구독자", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="likes", label="좋아요", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="comments", label="댓글", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="shares", label="공유", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="uploaded-videos", label="업로드 영상", value=0.0, unit="count", trend=0.0, trend_direction="-"),
],
monthly_data=[],
daily_data=[],
top_content=[],
audience_data=AudienceData(age_groups=[], gender={"male": 0, "female": 0}, top_regions=[]),
has_uploads=False,
)
def inject_video_count(
self,
response: DashboardResponse,
period_video_count: int,
prev_period_video_count: int,
) -> None:
for metric in response.content_metrics:
if metric.id == "uploaded-videos":
metric.value = float(period_video_count)
video_trend = float(period_video_count - prev_period_video_count)
metric.trend = video_trend
metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-")
break
async def get_stats(
self,
mode: Literal["day", "month"],
platform_user_id: str | None,
current_user: User,
session: AsyncSession,
) -> DashboardResponse:
logger.info(
f"[DASHBOARD] 통계 조회 시작 - "
f"user_uuid={current_user.user_uuid}, mode={mode}, platform_user_id={platform_user_id}"
)
# 1. 날짜 계산
start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc = (
self.calculate_date_range(mode)
)
start_date = start_dt.strftime("%Y-%m-%d")
end_date = end_dt.strftime("%Y-%m-%d")
kpi_end_date = kpi_end_dt.strftime("%Y-%m-%d")
logger.debug(f"[1] 날짜 계산 완료 - period={period_desc}, start={start_date}, end={end_date}")
# 2. YouTube 계정 확인
social_account = await self.resolve_social_account(current_user, session, platform_user_id)
logger.debug(f"[2] YouTube 계정 확인 완료 - platform_user_id={social_account.platform_user_id}")
# 3. 영상 수 조회
period_video_count, prev_period_video_count = await self.get_video_counts(
current_user, session, social_account, start_dt, prev_start_dt, prev_kpi_end_dt
)
logger.debug(f"[3] 영상 수 - current={period_video_count}, prev={prev_period_video_count}")
# 4. 캐시 조회
cache_key = f"dashboard:{current_user.user_uuid}:{social_account.platform_user_id}:{mode}"
cached_raw = await get_cache(cache_key)
if cached_raw:
try:
payload = json.loads(cached_raw)
logger.info(f"[CACHE HIT] 캐시 반환 - user_uuid={current_user.user_uuid}")
response = DashboardResponse.model_validate(payload["response"])
self.inject_video_count(response, period_video_count, prev_period_video_count)
return response
except (json.JSONDecodeError, KeyError):
logger.warning(f"[CACHE PARSE ERROR] 포맷 오류, 무시 - key={cache_key}")
logger.debug("[4] 캐시 MISS - YouTube API 호출 필요")
# 5. 업로드 영상 조회
video_ids, video_lookup = await self.get_video_ids(current_user, session, social_account)
logger.debug(f"[5] 영상 조회 완료 - count={len(video_ids)}")
if not video_ids:
logger.info(f"[DASHBOARD] 업로드 영상 없음, 빈 응답 반환 - user_uuid={current_user.user_uuid}")
return self.build_empty_response()
# 6. 토큰 유효성 확인
try:
access_token = await SocialAccountService().ensure_valid_token(social_account, session)
except TokenExpiredError:
logger.warning(f"[TOKEN EXPIRED] 재연동 필요 - user_uuid={current_user.user_uuid}")
raise YouTubeTokenExpiredError()
logger.debug("[6] 토큰 유효성 확인 완료")
# 7. YouTube Analytics API 호출
youtube_service = YouTubeAnalyticsService()
raw_data = await youtube_service.fetch_all_metrics(
video_ids=video_ids,
start_date=start_date,
end_date=end_date,
kpi_end_date=kpi_end_date,
access_token=access_token,
mode=mode,
)
logger.debug("[7] YouTube Analytics API 호출 완료")
# 8. TopContent 조립
processor = DataProcessor()
top_content_rows = raw_data.get("top_videos", {}).get("rows", [])
top_content: list[TopContent] = []
for row in top_content_rows[:4]:
if len(row) < 4:
continue
video_id, views, likes, comments = row[0], row[1], row[2], row[3]
meta = video_lookup.get(video_id)
if not meta:
continue
title, uploaded_at = meta
engagement_rate = ((likes + comments) / views * 100) if views > 0 else 0
top_content.append(
TopContent(
id=video_id,
title=title,
thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg",
platform="youtube",
views=int(views),
engagement=f"{engagement_rate:.1f}%",
date=uploaded_at.strftime("%Y.%m.%d"),
)
)
logger.debug(f"[8] TopContent 조립 완료 - count={len(top_content)}")
# 9. 데이터 가공
dashboard_data = processor.process(raw_data, top_content, 0, mode=mode, end_date=end_date)
logger.debug("[9] 데이터 가공 완료")
# 10. 캐시 저장
cache_payload = json.dumps({"response": dashboard_data.model_dump(mode="json")})
cache_success = await set_cache(cache_key, cache_payload, ttl=43200)
if cache_success:
logger.debug(f"[CACHE SET] 캐시 저장 성공 - key={cache_key}")
else:
logger.warning(f"[CACHE SET] 캐시 저장 실패 - key={cache_key}")
# 11. 업로드 영상 수 주입
self.inject_video_count(dashboard_data, period_video_count, prev_period_video_count)
logger.info(
f"[DASHBOARD] 통계 조회 완료 - "
f"user_uuid={current_user.user_uuid}, mode={mode}, period={period_desc}, videos={len(video_ids)}"
)
return dashboard_data

View File

@ -143,8 +143,8 @@ class DataProcessor:
monthly_data = [] monthly_data = []
audience_data = self._build_audience_data( audience_data = self._build_audience_data(
raw_data.get("demographics") or {}, raw_data.get("demographics", {}),
raw_data.get("region") or {}, raw_data.get("region", {}),
) )
logger.debug( logger.debug(
f"[DataProcessor.process] SUCCESS - " f"[DataProcessor.process] SUCCESS - "

View File

@ -141,9 +141,6 @@ class YouTubeAnalyticsService:
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# 에러 체크 (YouTubeAuthError, YouTubeQuotaExceededError는 원형 그대로 전파) # 에러 체크 (YouTubeAuthError, YouTubeQuotaExceededError는 원형 그대로 전파)
# demographics(index 5)는 YouTubeAPIError 시 None으로 허용 (YouTube 서버 간헐적 오류 대응)
OPTIONAL_INDICES = {5, 6} # demographics, region
results = list(results)
for i, result in enumerate(results): for i, result in enumerate(results):
if isinstance(result, Exception): if isinstance(result, Exception):
logger.error( logger.error(
@ -151,12 +148,6 @@ class YouTubeAnalyticsService:
) )
if isinstance(result, (YouTubeAuthError, YouTubeQuotaExceededError)): if isinstance(result, (YouTubeAuthError, YouTubeQuotaExceededError)):
raise result raise result
if i in OPTIONAL_INDICES and isinstance(result, YouTubeAPIError):
logger.warning(
f"[YouTubeAnalyticsService] 선택적 API 호출 {i+1}/7 실패, None으로 처리: {result}"
)
results[i] = None
continue
raise YouTubeAPIError(f"데이터 조회 실패: {result.__class__.__name__}") raise YouTubeAPIError(f"데이터 조회 실패: {result.__class__.__name__}")
logger.debug( logger.debug(

View File

@ -6,7 +6,6 @@ from sqlalchemy.orm import DeclarativeBase
from app.utils.logger import get_logger from app.utils.logger import get_logger
from config import db_settings from config import db_settings
import traceback
logger = get_logger("database") logger = get_logger("database")
@ -25,10 +24,12 @@ engine = create_async_engine(
max_overflow=20, # 추가 연결: 20 (총 최대 40) max_overflow=20, # 추가 연결: 20 (총 최대 40)
pool_timeout=30, # 풀에서 연결 대기 시간 (초) pool_timeout=30, # 풀에서 연결 대기 시간 (초)
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정 pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
pool_use_lifo=True,
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결) pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화 pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
connect_args={ connect_args={
"connect_timeout": 10, # DB 연결 타임아웃 "connect_timeout": 10, # DB 연결 타임아웃
"read_timeout": 30,
"charset": "utf8mb4", "charset": "utf8mb4",
}, },
) )
@ -52,10 +53,12 @@ background_engine = create_async_engine(
max_overflow=10, # 추가 연결: 10 (총 최대 20) max_overflow=10, # 추가 연결: 10 (총 최대 20)
pool_timeout=60, # 백그라운드는 대기 시간 여유있게 pool_timeout=60, # 백그라운드는 대기 시간 여유있게
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정 pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
pool_use_lifo=True,
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결) pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
pool_reset_on_return="rollback", pool_reset_on_return="rollback",
connect_args={ connect_args={
"connect_timeout": 10, "connect_timeout": 10,
"read_timeout": 30,
"charset": "utf8mb4", "charset": "utf8mb4",
}, },
) )
@ -74,7 +77,7 @@ async def create_db_tables():
# 모델 import (테이블 메타데이터 등록용) # 모델 import (테이블 메타데이터 등록용)
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401 from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401 from app.home.models import Image, Project, MarketingIntel # noqa: F401
from app.lyric.models import Lyric # noqa: F401 from app.lyric.models import Lyric # noqa: F401
from app.song.models import Song, SongTimestamp # noqa: F401 from app.song.models import Song, SongTimestamp # noqa: F401
from app.video.models import Video # noqa: F401 from app.video.models import Video # noqa: F401
@ -97,7 +100,6 @@ async def create_db_tables():
SocialUpload.__table__, SocialUpload.__table__,
MarketingIntel.__table__, MarketingIntel.__table__,
Dashboard.__table__, Dashboard.__table__,
ImageTag.__table__,
] ]
logger.info("Creating database tables...") logger.info("Creating database tables...")
@ -130,13 +132,22 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
try: try:
yield session yield session
except Exception as e: except Exception as e:
import traceback from fastapi import HTTPException
await session.rollback() await session.rollback()
logger.error(traceback.format_exc()) duration = (time.perf_counter() - start_time) * 1000
logger.error( # 클라이언트 에러(4xx)는 WARNING, 서버 에러(5xx)는 ERROR로 구분
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, " if isinstance(e, HTTPException) and e.status_code < 500:
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" logger.warning(
) f"[get_session] ROLLBACK ({e.status_code}) - "
f"error: {type(e).__name__}: {e}, duration: {duration:.1f}ms"
)
else:
import traceback
logger.error(traceback.format_exc())
logger.error(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
f"duration: {duration:.1f}ms"
)
raise e raise e
finally: finally:
total_time = time.perf_counter() - start_time total_time = time.perf_counter() - start_time
@ -172,7 +183,6 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
f"error: {type(e).__name__}: {e}, " f"error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
) )
logger.debug(traceback.format_exc())
raise e raise e
finally: finally:
total_time = time.perf_counter() - start_time total_time = time.perf_counter() - start_time

View File

@ -9,10 +9,9 @@ import aiofiles
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import func, select
from app.database.session import get_session, AsyncSessionLocal from app.database.session import get_session, AsyncSessionLocal
from app.home.models import Image, MarketingIntel, ImageTag from app.home.models import Image, MarketingIntel
from app.user.dependencies.auth import get_current_user from app.user.dependencies.auth import get_current_user
from app.user.models import User from app.user.models import User
from app.home.schemas.home_schema import ( from app.home.schemas.home_schema import (
@ -30,13 +29,12 @@ from app.home.schemas.home_schema import (
) )
from app.home.services.naver_search import naver_search_client from app.home.services.naver_search import naver_search_client
from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.common import generate_task_id from app.utils.common import generate_task_id
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.nvMapScraper import NvMapScraper, GraphQLException, URLNotFoundException from app.utils.nvMapScraper import NvMapScraper, GraphQLException
from app.utils.nvMapPwScraper import NvMapPwScraper from app.utils.nvMapPwScraper import NvMapPwScraper
from app.utils.prompts.prompts import marketing_prompt from app.utils.prompts.prompts import marketing_prompt
from app.utils.autotag import autotag_images
from config import MEDIA_ROOT from config import MEDIA_ROOT
# 로거 설정 # 로거 설정
@ -220,15 +218,6 @@ async def _crawling_logic(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"네이버 지도 크롤링에 실패했습니다: {e}", detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
) )
except URLNotFoundException as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error(
f"[crawling] Step 1 FAILED - 크롤링 실패: {e} ({step1_elapsed:.1f}ms)"
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Place ID를 확인할 수 없습니다. URL을 확인하세요. : {e}",
)
except Exception as e: except Exception as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error( logger.error(
@ -462,6 +451,255 @@ IMAGES_JSON_EXAMPLE = """[
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"} {"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
]""" ]"""
@router.post(
"/image/upload/server",
include_in_schema=False,
summary="이미지 업로드 (로컬 서버)",
description="""
이미지를 로컬 서버(media 폴더) 업로드하고 새로운 task_id를 생성합니다.
## 요청 방식
multipart/form-data 형식으로 전송합니다.
## 요청 필드
- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택)
- **files**: 이미지 바이너리 파일 목록 (선택)
**주의**: images_json 또는 files 최소 하나는 반드시 전달해야 합니다.
## 지원 이미지 확장자
jpg, jpeg, png, webp, heic, heif
## images_json 예시
```json
[
{"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
]
```
## 바이너리 파일 업로드 테스트 방법
### 1. Swagger UI에서 테스트
1. 엔드포인트의 "Try it out" 버튼 클릭
2. task_id 입력 (: test-task-001)
3. files 항목에서 "Add item" 클릭하여 로컬 이미지 파일 선택
4. (선택) images_json에 URL 목록 JSON 입력
5. "Execute" 버튼 클릭
### 2. cURL로 테스트
```bash
# 바이너리 파일만 업로드
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
-F "files=@/path/to/image1.jpg" \\
-F "files=@/path/to/image2.png"
# URL + 바이너리 파일 동시 업로드
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
-F "files=@/path/to/local_image.jpg"
```
### 3. Python requests로 테스트
```python
import requests
url = "http://localhost:8000/image/upload/server/test-task-001"
files = [
("files", ("image1.jpg", open("image1.jpg", "rb"), "image/jpeg")),
("files", ("image2.png", open("image2.png", "rb"), "image/png")),
]
data = {
"images_json": '[{"url": "https://example.com/image.jpg"}]'
}
response = requests.post(url, files=files, data=data)
print(response.json())
```
## 반환 정보
- **task_id**: 작업 고유 식별자
- **total_count**: 업로드된 이미지 개수
- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장)
- **file_count**: 파일로 업로드된 이미지 개수 (media 폴더에 저장)
- **saved_count**: Image 테이블에 저장된 row
- **images**: 업로드된 이미지 목록
- **source**: "url" (외부 URL) 또는 "file" (로컬 서버 저장)
## 저장 경로
- 바이너리 파일: /media/image/{날짜}/{uuid7}/{파일명}
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
## 반환 정보
- **task_id**: 새로 생성된 작업 고유 식별자
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
""",
response_model=ImageUploadResponse,
responses={
200: {"description": "이미지 업로드 성공"},
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
},
tags=["Image-Server"],
)
async def upload_images(
images_json: Optional[str] = Form(
default=None,
description="외부 이미지 URL 목록 (JSON 문자열)",
examples=[IMAGES_JSON_EXAMPLE],
),
files: Optional[list[UploadFile]] = File(
default=None, description="이미지 바이너리 파일 목록"
),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> ImageUploadResponse:
"""이미지 업로드 (URL + 바이너리 파일)"""
# task_id 생성
task_id = await generate_task_id()
# 1. 진입 검증: images_json 또는 files 중 하나는 반드시 있어야 함
has_images_json = images_json is not None and images_json.strip() != ""
has_files = files is not None and len(files) > 0
if not has_images_json and not has_files:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
)
# 2. images_json 파싱 (있는 경우만)
url_images: list[ImageUrlItem] = []
if has_images_json:
try:
parsed = json.loads(images_json)
if isinstance(parsed, list):
url_images = [ImageUrlItem(**item) for item in parsed if item]
except (json.JSONDecodeError, TypeError, ValueError) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"images_json 파싱 오류: {str(e)}",
)
# 3. 유효한 파일만 필터링 (빈 파일, 유효한 이미지 확장자가 아닌 경우 제외)
valid_files: list[UploadFile] = []
skipped_files: list[str] = []
if has_files and files:
for f in files:
is_valid_ext = _is_valid_image_extension(f.filename)
is_not_empty = (
f.size is None or f.size > 0
) # size가 None이면 아직 읽지 않은 것
is_real_file = (
f.filename and f.filename != "filename"
) # Swagger 빈 파일 체크
if f and is_real_file and is_valid_ext and is_not_empty:
valid_files.append(f)
else:
skipped_files.append(f.filename or "unknown")
# 유효한 데이터가 하나도 없으면 에러
if not url_images and not valid_files:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"유효한 이미지가 없습니다. 지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. 건너뛴 파일: {skipped_files}",
)
result_images: list[ImageUploadResultItem] = []
img_order = 0
# 1. URL 이미지 저장
for url_item in url_images:
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
image = Image(
task_id=task_id,
img_name=img_name,
img_url=url_item.url,
img_order=img_order,
)
session.add(image)
await session.flush() # ID 생성을 위해 flush
result_images.append(
ImageUploadResultItem(
id=image.id,
img_name=img_name,
img_url=url_item.url,
img_order=img_order,
source="url",
)
)
img_order += 1
# 2. 바이너리 파일을 media에 저장
if valid_files:
today = date.today().strftime("%Y-%m-%d")
# 한 번의 요청에서 업로드된 모든 이미지는 같은 폴더에 저장
batch_uuid = await generate_task_id()
upload_dir = MEDIA_ROOT / "image" / today / batch_uuid
upload_dir.mkdir(parents=True, exist_ok=True)
for file in valid_files:
# 파일명: 원본 파일명 사용 (중복 방지를 위해 순서 추가)
original_name = file.filename or "image"
ext = _get_file_extension(file.filename) # type: ignore[arg-type]
# 파일명에서 확장자 제거 후 순서 추가
name_without_ext = (
original_name.rsplit(".", 1)[0]
if "." in original_name
else original_name
)
filename = f"{name_without_ext}_{img_order:03d}{ext}"
save_path = upload_dir / filename
# media에 파일 저장
await _save_upload_file(file, save_path)
# media 기준 URL 생성
img_url = f"/media/image/{today}/{batch_uuid}/{filename}"
img_name = file.filename or filename
image = Image(
task_id=task_id,
img_name=img_name,
img_url=img_url, # Media URL을 DB에 저장
img_order=img_order,
)
session.add(image)
await session.flush()
result_images.append(
ImageUploadResultItem(
id=image.id,
img_name=img_name,
img_url=img_url,
img_order=img_order,
source="file",
)
)
img_order += 1
saved_count = len(result_images)
await session.commit()
# Image 테이블에서 현재 task_id의 이미지 URL 목록 조회
image_urls = [img.img_url for img in result_images]
return ImageUploadResponse(
task_id=task_id,
total_count=len(result_images),
url_count=len(url_images),
file_count=len(valid_files),
saved_count=saved_count,
images=result_images,
image_urls=image_urls,
)
@router.post( @router.post(
"/image/upload/blob", "/image/upload/blob",
summary="이미지 업로드 (Azure Blob Storage)", summary="이미지 업로드 (Azure Blob Storage)",
@ -750,10 +988,6 @@ async def upload_images_blob(
saved_count = len(result_images) saved_count = len(result_images)
image_urls = [img.img_url for img in result_images] image_urls = [img.img_url for img in result_images]
logger.info(f"[image_tagging] START - task_id: {task_id}")
await tag_images_if_not_exist(image_urls)
logger.info(f"[image_tagging] Done - task_id: {task_id}")
total_time = time.perf_counter() - request_start total_time = time.perf_counter() - request_start
logger.info( logger.info(
f"[upload_images_blob] SUCCESS - task_id: {task_id}, " f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
@ -769,36 +1003,3 @@ async def upload_images_blob(
images=result_images, images=result_images,
image_urls=image_urls, image_urls=image_urls,
) )
async def tag_images_if_not_exist(
image_urls : list[str]
) -> None:
# 1. 조회
async with AsyncSessionLocal() as session:
stmt = (
select(ImageTag)
.where(ImageTag.img_url_hash.in_([func.crc32(url) for url in image_urls]))
.where(ImageTag.img_url.in_(image_urls))
)
image_tags_query_results = await session.execute(stmt)
image_tags = image_tags_query_results.scalars().all()
existing_urls = {tag.img_url for tag in image_tags}
new_tags = [
ImageTag(img_url=url, img_tag=None)
for url in image_urls
if url not in existing_urls
]
session.add_all(new_tags)
null_tags = [tag for tag in image_tags if tag.img_tag is None] + new_tags
if null_tags:
tag_datas = await autotag_images([img.img_url for img in null_tags])
print(tag_datas)
for tag, tag_data in zip(null_tags, tag_datas):
tag.img_tag = tag_data.model_dump(mode="json")
await session.commit()

View File

@ -9,8 +9,7 @@ Home 모듈 SQLAlchemy 모델 정의
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List, Optional, Any from typing import TYPE_CHECKING, List, Optional, Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Computed, Index, Integer, String, Text, JSON, func from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, JSON, func
from sqlalchemy.dialects.mysql import INTEGER
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
@ -301,12 +300,6 @@ class MarketingIntel(Base):
comment="마케팅 인텔리전스 결과물", comment="마케팅 인텔리전스 결과물",
) )
subtitle : Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=True,
comment="자막 정보 생성 결과물",
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
@ -315,50 +308,13 @@ class MarketingIntel(Base):
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return ( task_id_str = (
f"<MarketingIntel(id={self.id}, place_id='{self.place_id}')>" (self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
)
img_name_str = (
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
) )
return (
class ImageTag(Base): f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
""" )
이미지 태그 테이블
"""
__tablename__ = "image_tags"
__table_args__ = (
Index("idx_img_url_hash", "img_url_hash"), # CRC32 index
{
"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="고유 식별자",
)
img_url: Mapped[str] = mapped_column(
String(2048),
nullable=False,
comment="이미지 URL (blob, CDN 경로)",
)
img_url_hash: Mapped[int] = mapped_column(
INTEGER(unsigned=True),
Computed("CRC32(img_url)", persisted=True), # generated column
comment="URL CRC32 해시 (검색용 index)",
)
img_tag: Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=True,
default=False,
comment="태그 JSON",
)

View File

@ -41,8 +41,8 @@ from app.lyric.schemas.lyric import (
LyricListItem, LyricListItem,
LyricStatusResponse, LyricStatusResponse,
) )
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background from app.lyric.worker.lyric_task import generate_lyric_background
from app.utils.prompts.chatgpt_prompt import ChatgptService from app.utils.chatgpt_prompt import ChatgptService
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse, get_paginated from app.utils.pagination import PaginatedResponse, get_paginated
@ -253,6 +253,17 @@ async def generate_lyric(
step1_start = time.perf_counter() step1_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...") logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
# service = ChatgptService(
# customer_name=request_body.customer_name,
# region=request_body.region,
# detail_region_info=request_body.detail_region_info or "",
# language=request_body.language,
# )
# prompt = service.build_lyrics_prompt()
# 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨.
# 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성
promotional_expressions = { promotional_expressions = {
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소", "Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway", "English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
@ -340,7 +351,7 @@ async def generate_lyric(
# ========== Step 4: 백그라운드 태스크 스케줄링 ========== # ========== Step 4: 백그라운드 태스크 스케줄링 ==========
step4_start = time.perf_counter() step4_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...") logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
orientation = request_body.orientation
background_tasks.add_task( background_tasks.add_task(
generate_lyric_background, generate_lyric_background,
task_id=task_id, task_id=task_id,
@ -348,12 +359,6 @@ async def generate_lyric(
lyric_input_data=lyric_input_data, lyric_input_data=lyric_input_data,
lyric_id=lyric.id, lyric_id=lyric.id,
) )
background_tasks.add_task(
generate_subtitle_background,
orientation = orientation,
task_id=task_id
)
step4_elapsed = (time.perf_counter() - step4_start) * 1000 step4_elapsed = (time.perf_counter() - step4_start) * 1000
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)") logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")

View File

@ -23,7 +23,7 @@ Lyric API Schemas
""" """
from datetime import datetime from datetime import datetime
from typing import Optional, Literal from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@ -42,8 +42,7 @@ class GenerateLyricRequest(BaseModel):
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean", "language": "Korean",
"m_id" : 2, "m_id" : 1
"orientation" : "vertical"
} }
""" """
@ -55,8 +54,7 @@ class GenerateLyricRequest(BaseModel):
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean", "language": "Korean",
"m_id" : 1, "m_id" : 1
"orientation" : "vertical"
} }
} }
) )
@ -70,11 +68,7 @@ class GenerateLyricRequest(BaseModel):
language: str = Field( language: str = Field(
default="Korean", default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
), )
orientation: Literal["horizontal", "vertical"] = Field(
default="vertical",
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
),
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값") m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")

View File

@ -7,15 +7,11 @@ Lyric Background Tasks
import traceback import traceback
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.home.models import Image, Project, MarketingIntel
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.subtitles import SubtitleContentsGenerator
from app.utils.creatomate import CreatomateService
from app.utils.prompts.prompts import Prompt from app.utils.prompts.prompts import Prompt
from app.utils.logger import get_logger from app.utils.logger import get_logger
@ -104,6 +100,13 @@ async def generate_lyric_background(
step1_start = time.perf_counter() step1_start = time.perf_counter()
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...") logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
# service = ChatgptService(
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
# region="",
# detail_region_info="",
# language=language,
# )
chatgpt = ChatgptService() chatgpt = ChatgptService()
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
@ -155,55 +158,3 @@ async def generate_lyric_background(
elapsed = (time.perf_counter() - task_start) * 1000 elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True) logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id) await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
async def generate_subtitle_background(
orientation: str,
task_id: str
) -> None:
logger.info(f"[generate_subtitle_background] task_id: {task_id}, {orientation}")
creatomate_service = CreatomateService(orientation=orientation)
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
pitchings = creatomate_service.extract_text_format_from_template(template)
subtitle_generator = SubtitleContentsGenerator()
async with BackgroundSessionLocal() as session:
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
store_address = project.detail_region_info
customer_name = project.store_name
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, {store_address}")
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
marketing_intelligence = marketing_intelligence.intel_result,
pitching_label_list = pitchings,
customer_name = customer_name,
detail_region_info = store_address,
)
pitching_output_list = generated_subtitles.pitching_results
subtitle_modifications = {pitching_output.pitching_tag : pitching_output.pitching_data for pitching_output in pitching_output_list}
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
async with BackgroundSessionLocal() as session:
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
marketing_intelligence.subtitle = subtitle_modifications
await session.commit()
logger.info(f"[generate_subtitle_background] task_id: {task_id} DONE")
return

View File

@ -0,0 +1,209 @@
"""
SNS OAuth API 라우터
Facebook OAuth 연동 관련 엔드포인트를 제공합니다.
"""
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Query
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.sns.schemas.facebook_schema import FacebookConnectResponse
from app.sns.services.facebook import facebook_service
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.logger import get_logger
from config import social_oauth_settings
logger = get_logger(__name__)
router = APIRouter(prefix="/sns", tags=["SNS OAuth"])
def _build_redirect_url(is_success: bool, params: dict) -> str:
"""OAuth 완료 후 프론트엔드 리다이렉트 URL 생성"""
base_url = social_oauth_settings.OAUTH_FRONTEND_URL.rstrip("/")
path = (
social_oauth_settings.OAUTH_SUCCESS_PATH
if is_success
else social_oauth_settings.OAUTH_ERROR_PATH
)
return f"{base_url}{path}?{urlencode(params)}"
@router.get(
"/facebook/connect",
response_model=FacebookConnectResponse,
summary="Facebook OAuth 연동 시작",
description="""
## 개요
Facebook OAuth 2.0 인증을 시작합니다.
## 플로우
1. 엔드포인트를 호출하여 `auth_url` `state` 받음
2. 프론트엔드에서 `auth_url` 사용자를 리다이렉트
3. 사용자가 Facebook에서 로그인 권한 승인
4. Facebook이 `/sns/facebook/callback` 엔드포인트로 리다이렉트
5. 연동 완료 프론트엔드로 리다이렉트
## 인증
- Bearer 토큰 필요 (Authorization: Bearer <token>)
""",
responses={
200: {"description": "인증 URL 반환 성공"},
401: {"description": "인증 실패"},
},
)
async def facebook_connect(
current_user: User = Depends(get_current_user),
) -> FacebookConnectResponse:
"""Facebook OAuth 연동을 시작합니다."""
logger.info(f"[SNS_OAUTH] Facebook 연동 시작 - user_uuid: {current_user.user_uuid}")
# FacebookService를 통해 연동 시작
response = await facebook_service.start_connect(user_uuid=current_user.user_uuid)
logger.info("[SNS_OAUTH] Facebook 연동 URL 생성 완료")
return response
@router.get(
"/facebook/callback",
summary="Facebook OAuth 콜백",
description="""
## 개요
Facebook OAuth 콜백을 처리합니다.
엔드포인트는 Facebook에서 직접 호출되며,
처리 완료 프론트엔드로 리다이렉트합니다.
## 파라미터
- **code**: Facebook에서 발급한 인가 코드
- **state**: CSRF 방지용 state 토큰
- **error**: OAuth 에러 코드 (사용자 취소 )
""",
responses={
302: {"description": "프론트엔드로 리다이렉트"},
},
)
async def facebook_callback(
code: str | None = Query(None, description="Facebook 인가 코드"),
state: str | None = Query(None, description="CSRF 방지용 state 토큰"),
error: str | None = Query(None, description="OAuth 에러 코드"),
error_description: str | None = Query(None, description="OAuth 에러 설명"),
session: AsyncSession = Depends(get_session),
) -> RedirectResponse:
"""Facebook OAuth 콜백을 처리합니다."""
# 사용자가 취소하거나 에러가 발생한 경우
if error:
logger.info(
f"[SNS_OAUTH] Facebook 콜백 에러/취소 - "
f"error: {error}, description: {error_description}"
)
# 에러 메시지 분기
if error == "access_denied":
error_message = "사용자가 Facebook 연동을 취소했습니다."
else:
error_message = error_description or error
redirect_url = _build_redirect_url(
is_success=False,
params={
"platform": "facebook",
"error": error_message,
"cancelled": "true" if error == "access_denied" else "false",
},
)
return RedirectResponse(url=redirect_url, status_code=302)
# code 또는 state가 없는 경우
if not code or not state:
logger.warning(
f"[SNS_OAUTH] Facebook 콜백 파라미터 누락 - "
f"code: {bool(code)}, state: {bool(state)}"
)
redirect_url = _build_redirect_url(
is_success=False,
params={
"platform": "facebook",
"error": "잘못된 요청입니다. 다시 시도해주세요.",
},
)
return RedirectResponse(url=redirect_url, status_code=302)
logger.info(f"[SNS_OAUTH] Facebook 콜백 수신 - code: {code[:20]}...")
try:
# FacebookService를 통해 콜백 처리
account_response = await facebook_service.handle_callback(
code=code,
state=state,
session=session,
)
# 성공 시 프론트엔드로 리다이렉트
redirect_url = _build_redirect_url(
is_success=True,
params={
"platform": "facebook",
"account_id": account_response.account_id,
"channel_name": account_response.platform_username,
},
)
logger.info("[SNS_OAUTH] Facebook 연동 성공, 리다이렉트")
return RedirectResponse(url=redirect_url, status_code=302)
except Exception as e:
logger.error(f"[SNS_OAUTH] Facebook 콜백 처리 실패 - error: {e}")
# 실패 시 에러 페이지로 리다이렉트
redirect_url = _build_redirect_url(
is_success=False,
params={
"platform": "facebook",
"error": str(e),
},
)
return RedirectResponse(url=redirect_url, status_code=302)
@router.delete(
"/facebook/disconnect",
summary="Facebook 계정 연동 해제",
description="""
## 개요
Facebook 계정 연동을 해제합니다.
## 연동 해제 시
- Facebook으로의 업로드가 불가능해집니다
- 기존 업로드 기록은 유지됩니다
- 재연동 다시 권한 승인이 필요합니다
## 인증
- Bearer 토큰 필요 (Authorization: Bearer <token>)
""",
responses={
200: {"description": "연동 해제 성공"},
401: {"description": "인증 실패"},
404: {"description": "연동된 Facebook 계정 없음"},
},
)
async def facebook_disconnect(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Facebook 계정 연동을 해제합니다."""
logger.info(f"[SNS_OAUTH] Facebook 연동 해제 - user_uuid: {current_user.user_uuid}")
# FacebookService를 통해 연동 해제
await facebook_service.disconnect(
user_uuid=current_user.user_uuid,
session=session,
)
logger.info("[SNS_OAUTH] Facebook 연동 해제 완료")
return {"success": True, "message": "Facebook 계정 연동이 해제되었습니다."}

View File

@ -0,0 +1,72 @@
"""
SNS 모듈 전용 FastAPI 의존성
SNS 모듈에서만 사용하는 의존성을 정의합니다.
Facebook OAuth 관련 의존성을 포함합니다.
"""
from fastapi import Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.user.dependencies.auth import get_current_user
from app.user.models import Platform, SocialAccount, User
from app.utils.facebook_oauth import FacebookOAuthClient
from app.utils.logger import get_logger
logger = get_logger(__name__)
def get_facebook_oauth_client() -> FacebookOAuthClient:
"""
FacebookOAuthClient 인스턴스를 제공하는 의존성
Returns:
FacebookOAuthClient: Facebook OAuth API 클라이언트 인스턴스
"""
return FacebookOAuthClient()
async def get_facebook_social_account(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SocialAccount:
"""
현재 사용자의 활성 Facebook 소셜 계정을 조회하는 의존성
Args:
current_user: 현재 인증된 사용자
session: DB 세션
Returns:
SocialAccount: 활성 Facebook 소셜 계정
Raises:
HTTPException: Facebook 소셜 계정이 없는 경우 404 반환
"""
logger.debug(f"[SNS_DEP] Facebook 소셜 계정 조회 - user_uuid: {current_user.user_uuid}")
# SocialAccount에서 Facebook 활성 계정 조회
result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == current_user.user_uuid,
SocialAccount.platform == Platform.FACEBOOK,
SocialAccount.is_active == True, # noqa: E712
SocialAccount.is_deleted == False, # noqa: E712
)
)
social_account = result.scalar_one_or_none()
if social_account is None:
logger.warning(f"[SNS_DEP] Facebook 계정 없음 - user_uuid: {current_user.user_uuid}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "FACEBOOK_ACCOUNT_NOT_FOUND",
"message": "연동된 Facebook 계정을 찾을 수 없습니다.",
},
)
logger.debug(f"[SNS_DEP] Facebook 소셜 계정 확인 - account_id: {social_account.id}")
return social_account

View File

@ -52,6 +52,7 @@ class SNSUploadTask(Base):
Index("idx_sns_upload_task_user_uuid", "user_uuid"), Index("idx_sns_upload_task_user_uuid", "user_uuid"),
Index("idx_sns_upload_task_task_id", "task_id"), Index("idx_sns_upload_task_task_id", "task_id"),
Index("idx_sns_upload_task_social_account_id", "social_account_id"), Index("idx_sns_upload_task_social_account_id", "social_account_id"),
Index("idx_sns_upload_task_platform", "platform"),
Index("idx_sns_upload_task_status", "status"), Index("idx_sns_upload_task_status", "status"),
Index("idx_sns_upload_task_is_scheduled", "is_scheduled"), Index("idx_sns_upload_task_is_scheduled", "is_scheduled"),
Index("idx_sns_upload_task_scheduled_at", "scheduled_at"), Index("idx_sns_upload_task_scheduled_at", "scheduled_at"),
@ -116,6 +117,12 @@ class SNSUploadTask(Base):
comment="소셜 계정 외래키 (SocialAccount.id 참조)", comment="소셜 계정 외래키 (SocialAccount.id 참조)",
) )
platform: Mapped[Optional[str]] = mapped_column(
String(20),
nullable=True,
comment="업로드 대상 플랫폼 (instagram, facebook 등)",
)
# ========================================================================== # ==========================================================================
# 업로드 콘텐츠 # 업로드 콘텐츠
# ========================================================================== # ==========================================================================
@ -131,6 +138,18 @@ class SNSUploadTask(Base):
comment="게시물 캡션/설명", comment="게시물 캡션/설명",
) )
platform_post_id: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="업로드 후 플랫폼에서 반환한 게시물 ID",
)
platform_post_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="업로드 후 게시물 URL (permalink)",
)
# ========================================================================== # ==========================================================================
# 발행 상태 # 발행 상태
# ========================================================================== # ==========================================================================

View File

@ -0,0 +1,122 @@
"""
Facebook OAuth API Schemas
Facebook OAuth 연동 관련 Pydantic 스키마를 정의합니다.
"""
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class FacebookConnectResponse(BaseModel):
"""Facebook OAuth 연동 시작 응답
Usage:
GET /sns/facebook/connect
Facebook OAuth 인증 URL과 CSRF state 토큰을 반환합니다.
Example Response:
{
"auth_url": "https://www.facebook.com/v21.0/dialog/oauth?client_id=...",
"state": "abc123xyz"
}
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"auth_url": "https://www.facebook.com/v21.0/dialog/oauth?client_id=123&redirect_uri=...",
"state": "abc123xyz456",
}
}
)
auth_url: str = Field(..., description="Facebook 인증 페이지 URL")
state: str = Field(..., description="CSRF 방지용 state 토큰")
class FacebookTokenResponse(BaseModel):
"""Facebook 토큰 교환 응답
Facebook Graph API에서 반환하는 액세스 토큰 정보입니다.
"""
access_token: str = Field(..., description="액세스 토큰")
token_type: str = Field(default="bearer", description="토큰 타입")
expires_in: int = Field(..., description="만료 시간 (초)")
class FacebookUserInfo(BaseModel):
"""Facebook 사용자 정보
Graph API /me 엔드포인트에서 반환하는 사용자 프로필 정보입니다.
"""
id: str = Field(..., description="Facebook 사용자 ID")
name: str = Field(..., description="사용자 이름")
email: Optional[str] = Field(default=None, description="이메일 (동의 시 제공)")
picture: Optional[dict] = Field(default=None, description="프로필 사진 정보")
class FacebookPageInfo(BaseModel):
"""Facebook 페이지 정보
사용자가 관리하는 Facebook 페이지 정보입니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": "123456789",
"name": "My Business Page",
"access_token": "EAA...",
"category": "Business",
}
}
)
id: str = Field(..., description="페이지 ID")
name: str = Field(..., description="페이지 이름")
access_token: str = Field(..., description="페이지 액세스 토큰")
category: Optional[str] = Field(default=None, description="페이지 카테고리")
class FacebookAccountResponse(BaseModel):
"""Facebook 연동 완료 응답
Usage:
Facebook OAuth 콜백 처리 완료 반환되는 연동 결과입니다.
Example Response:
{
"success": true,
"message": "Facebook 계정 연동이 완료되었습니다.",
"account_id": 1,
"platform_user_id": "123456789",
"platform_username": "홍길동",
"pages": [...]
}
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"message": "Facebook 계정 연동이 완료되었습니다.",
"account_id": 1,
"platform_user_id": "123456789",
"platform_username": "홍길동",
"pages": [],
}
}
)
success: bool = Field(..., description="연동 성공 여부")
message: str = Field(..., description="결과 메시지")
account_id: int = Field(..., description="SocialAccount ID")
platform_user_id: str = Field(..., description="Facebook 사용자 ID")
platform_username: str = Field(..., description="Facebook 사용자 이름")
pages: Optional[list[FacebookPageInfo]] = Field(
default=None, description="관리 가능한 Facebook 페이지 목록"
)

View File

@ -0,0 +1,287 @@
"""
Facebook OAuth 서비스
Facebook OAuth 연동 관련 비즈니스 로직을 처리합니다.
모든 Facebook API 호출은 FacebookOAuthClient를 통해서만 수행됩니다.
"""
import json
import secrets
from datetime import timedelta
from redis.asyncio import Redis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.sns.schemas.facebook_schema import (
FacebookAccountResponse,
FacebookConnectResponse,
FacebookPageInfo,
)
from app.user.models import Platform, SocialAccount
from app.utils.facebook_oauth import FacebookOAuthClient
from app.utils.logger import get_logger
from app.utils.timezone import now
from config import db_settings, social_oauth_settings
logger = get_logger(__name__)
# Facebook OAuth용 Redis 클라이언트 (DB 3 사용 - social과 분리)
_redis_client = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=3,
decode_responses=True,
)
class FacebookService:
"""
Facebook OAuth 연동 서비스
OAuth 인증 시작, 콜백 처리, 연동 해제 기능을 제공합니다.
모든 Facebook Graph API 호출은 FacebookOAuthClient를 통해 수행됩니다.
"""
# Redis key prefix for Facebook OAuth state
STATE_KEY_PREFIX = "facebook:oauth:state:"
def __init__(self) -> None:
# FacebookOAuthClient 인스턴스 생성
self.oauth_client = FacebookOAuthClient()
async def start_connect(self, user_uuid: str) -> FacebookConnectResponse:
"""
Facebook OAuth 연동 시작
CSRF state 토큰을 생성하고 Redis에 저장한 ,
Facebook 인증 페이지 URL을 반환합니다.
Args:
user_uuid: 사용자 UUID
Returns:
FacebookConnectResponse: 인증 URL state 토큰
"""
logger.info(f"[FACEBOOK_SVC] 연동 시작 - user_uuid: {user_uuid}")
# 1. CSRF state 토큰 생성
state = secrets.token_urlsafe(32)
logger.debug(f"[FACEBOOK_SVC] state 토큰 생성 - state: {state[:20]}...")
# 2. Redis에 state:user_uuid 매핑 저장 (TTL 적용)
state_key = f"{self.STATE_KEY_PREFIX}{state}"
state_data = json.dumps({"user_uuid": user_uuid})
await _redis_client.setex(
state_key,
social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
state_data,
)
logger.debug(
f"[FACEBOOK_SVC] Redis state 저장 - "
f"key: {state_key}, ttl: {social_oauth_settings.OAUTH_STATE_TTL_SECONDS}"
)
# 3. Facebook 인증 페이지 URL 생성
auth_url = self.oauth_client.get_authorization_url(state)
logger.info("[FACEBOOK_SVC] 연동 시작 완료 - 인증 URL 생성됨")
return FacebookConnectResponse(auth_url=auth_url, state=state)
async def handle_callback(
self, code: str, state: str, session: AsyncSession
) -> FacebookAccountResponse:
"""
Facebook OAuth 콜백 처리
인가 코드를 토큰으로 교환하고, 장기 토큰 발급
사용자 정보를 조회하여 SocialAccount에 저장합니다.
Args:
code: Facebook OAuth 인가 코드
state: CSRF 방지용 state 토큰
session: DB 세션
Returns:
FacebookAccountResponse: 연동 완료 정보
Raises:
HTTPException: state 무효, 토큰 교환 실패
"""
logger.info(f"[FACEBOOK_SVC] 콜백 처리 시작 - state: {state[:20]}...")
# 1. Redis에서 state로 user_uuid 조회 및 검증
state_key = f"{self.STATE_KEY_PREFIX}{state}"
state_data_str = await _redis_client.get(state_key)
if state_data_str is None:
logger.warning(f"[FACEBOOK_SVC] state 토큰 없음 또는 만료 - state: {state[:20]}...")
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "FACEBOOK_STATE_EXPIRED",
"message": "인증 세션이 만료되었습니다. 다시 시도해주세요.",
},
)
# state 데이터 파싱 및 삭제 (일회성)
state_data = json.loads(state_data_str)
user_uuid = state_data["user_uuid"]
await _redis_client.delete(state_key)
logger.debug(f"[FACEBOOK_SVC] state 검증 완료 및 삭제 - user_uuid: {user_uuid}")
# 2. 인가 코드를 단기 액세스 토큰으로 교환
short_token_data = await self.oauth_client.get_access_token(code)
short_lived_token = short_token_data["access_token"]
logger.debug("[FACEBOOK_SVC] 단기 토큰 획득 완료")
# 3. 단기 토큰을 장기 토큰으로 교환 (약 60일)
long_token_data = await self.oauth_client.exchange_long_lived_token(short_lived_token)
long_lived_token = long_token_data["access_token"]
expires_in = long_token_data.get("expires_in", 5184000) # 기본 60일
logger.debug(f"[FACEBOOK_SVC] 장기 토큰 교환 완료 - expires_in: {expires_in}")
# 4. 사용자 정보 조회
user_info = await self.oauth_client.get_user_info(long_lived_token)
facebook_user_id = user_info["id"]
facebook_user_name = user_info.get("name", "")
facebook_picture = user_info.get("picture", {})
logger.debug(
f"[FACEBOOK_SVC] 사용자 정보 조회 완료 - "
f"id: {facebook_user_id}, name: {facebook_user_name}"
)
# 5. 사용자 관리 페이지 목록 조회
pages = []
try:
pages_data = await self.oauth_client.get_user_pages(facebook_user_id, long_lived_token)
pages = [
FacebookPageInfo(
id=page["id"],
name=page.get("name", ""),
access_token=page.get("access_token", ""),
category=page.get("category"),
)
for page in pages_data
]
logger.debug(f"[FACEBOOK_SVC] 페이지 목록 조회 완료 - 페이지 수: {len(pages)}")
except Exception as e:
# 페이지 조회 실패는 연동 실패로 처리하지 않음
logger.warning(f"[FACEBOOK_SVC] 페이지 목록 조회 실패 (무시) - error: {str(e)}")
# 6. SocialAccount 생성 또는 업데이트 (UPSERT)
token_expires_at = now() + timedelta(seconds=expires_in)
platform_data = {
"picture_url": facebook_picture.get("data", {}).get("url") if isinstance(facebook_picture, dict) else None,
"pages": [page.model_dump() for page in pages],
}
# 기존 계정 조회 (user_uuid + platform + platform_user_id 기준)
existing_result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == user_uuid,
SocialAccount.platform == Platform.FACEBOOK,
SocialAccount.platform_user_id == facebook_user_id,
)
)
existing_account = existing_result.scalar_one_or_none()
if existing_account:
# 기존 계정 업데이트 (토큰 갱신 + 재활성화)
logger.info(f"[FACEBOOK_SVC] 기존 계정 업데이트 - account_id: {existing_account.id}")
existing_account.access_token = long_lived_token
existing_account.token_expires_at = token_expires_at
existing_account.platform_username = facebook_user_name
existing_account.platform_data = platform_data
existing_account.scope = self.oauth_client.scope
existing_account.is_active = True
existing_account.is_deleted = False
existing_account.connected_at = now()
await session.commit()
await session.refresh(existing_account)
account = existing_account
else:
# 새 소셜 계정 생성
logger.info(f"[FACEBOOK_SVC] 새 계정 생성 - user_uuid: {user_uuid}")
account = SocialAccount(
user_uuid=user_uuid,
platform=Platform.FACEBOOK,
access_token=long_lived_token,
refresh_token=None, # Facebook은 refresh_token 미지원
token_expires_at=token_expires_at,
scope=self.oauth_client.scope,
platform_user_id=facebook_user_id,
platform_username=facebook_user_name,
platform_data=platform_data,
is_active=True,
is_deleted=False,
)
session.add(account)
await session.commit()
await session.refresh(account)
logger.info(
f"[FACEBOOK_SVC] 연동 완료 - "
f"account_id: {account.id}, platform_user_id: {facebook_user_id}"
)
return FacebookAccountResponse(
success=True,
message="Facebook 계정 연동이 완료되었습니다.",
account_id=account.id,
platform_user_id=facebook_user_id,
platform_username=facebook_user_name,
pages=pages if pages else None,
)
async def disconnect(self, user_uuid: str, session: AsyncSession) -> None:
"""
Facebook 계정 연동 해제
SocialAccount를 소프트 삭제 처리합니다.
Args:
user_uuid: 사용자 UUID
session: DB 세션
Raises:
HTTPException: 연동된 Facebook 계정이 없는 경우
"""
logger.info(f"[FACEBOOK_SVC] 연동 해제 시작 - user_uuid: {user_uuid}")
# 활성 Facebook 계정 조회
result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == user_uuid,
SocialAccount.platform == Platform.FACEBOOK,
SocialAccount.is_active == True, # noqa: E712
SocialAccount.is_deleted == False, # noqa: E712
)
)
account = result.scalar_one_or_none()
if account is None:
logger.warning(f"[FACEBOOK_SVC] 연동 해제 대상 없음 - user_uuid: {user_uuid}")
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "FACEBOOK_ACCOUNT_NOT_FOUND",
"message": "연동된 Facebook 계정을 찾을 수 없습니다.",
},
)
# 소프트 삭제 처리
account.is_active = False
account.is_deleted = True
await session.commit()
logger.info(f"[FACEBOOK_SVC] 연동 해제 완료 - account_id: {account.id}")
# 모듈 레벨 싱글턴 인스턴스
facebook_service = FacebookService()

View File

@ -17,7 +17,7 @@ from app.home.models import MarketingIntel, Project
from app.social.constants import YOUTUBE_SEO_HASH from app.social.constants import YOUTUBE_SEO_HASH
from app.social.schemas import YoutubeDescriptionResponse from app.social.schemas import YoutubeDescriptionResponse
from app.user.models import User from app.user.models import User
from app.utils.prompts.chatgpt_prompt import ChatgptService from app.utils.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import yt_upload_prompt from app.utils.prompts.prompts import yt_upload_prompt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -7,14 +7,14 @@ from sqlalchemy import Connection, text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.lyric.schemas.lyrics_schema import ( from app.lyrics.schemas.lyrics_schema import (
AttributeData, AttributeData,
PromptTemplateData, PromptTemplateData,
SongFormData, SongFormData,
SongSampleData, SongSampleData,
StoreData, StoreData,
) )
from app.utils.prompts.chatgpt_prompt import chatgpt_api from app.utils.chatgpt_prompt import chatgpt_api
logger = get_logger("song") logger = get_logger("song")

View File

@ -1,48 +0,0 @@
from pydantic.main import BaseModel
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import image_autotag_prompt
from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommended
import asyncio
async def autotag_image(image_url : str) -> list[str]: #tag_list
chatgpt = ChatgptService(model_type="gemini")
image_input_data = {
"img_url" : image_url,
"space_type" : list(SpaceType),
"subject" : list(Subject),
"camera" : list(Camera),
"motion_recommended" : list(MotionRecommended)
}
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False)
return image_result
async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_list
chatgpt = ChatgptService(model_type="gemini")
image_input_data_list = [{
"img_url" : image_url,
"space_type" : list(SpaceType),
"subject" : list(Subject),
"camera" : list(Camera),
"motion_recommended" : list(MotionRecommended)
}for image_url in image_url_list]
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
MAX_RETRY = 3 # 하드코딩, 어떻게 처리할지는 나중에
for _ in range(MAX_RETRY):
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
print("Failed", failed_idx)
if not failed_idx:
break
retried = await asyncio.gather(
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], False, silent=True) for i in failed_idx],
return_exceptions=True
)
for i, result in zip(failed_idx, retried):
image_result_list[i] = result
print("Failed", failed_idx)
return image_result_list

View File

@ -0,0 +1,95 @@
import json
import re
from pydantic import BaseModel
from openai import AsyncOpenAI
from app.utils.logger import get_logger
from config import apikey_settings, recovery_settings
from app.utils.prompts.prompts import Prompt
# 로거 설정
logger = get_logger("chatgpt")
class ChatGPTResponseError(Exception):
"""ChatGPT API 응답 에러"""
def __init__(self, status: str, error_code: str = None, error_message: str = None):
self.status = status
self.error_code = error_code
self.error_message = error_message
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
class ChatgptService:
"""ChatGPT API 서비스 클래스
"""
def __init__(self, timeout: float = None):
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
self.client = AsyncOpenAI(
api_key=apikey_settings.CHATGPT_API_KEY,
timeout=self.timeout
)
async def _call_pydantic_output(self, prompt : str, output_format : BaseModel, model : str) -> BaseModel: # 입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
content = [{"type": "input_text", "text": prompt}]
last_error = None
for attempt in range(self.max_retries + 1):
response = await self.client.responses.parse(
model=model,
input=[{"role": "user", "content": content}],
text_format=output_format
)
# Response 디버그 로깅
logger.debug(f"[ChatgptService] attempt: {attempt}")
logger.debug(f"[ChatgptService] Response ID: {response.id}")
logger.debug(f"[ChatgptService] Response status: {response.status}")
logger.debug(f"[ChatgptService] Response model: {response.model}")
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
if response.status == "completed":
logger.debug(f"[ChatgptService] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
structured_output = response.output_parsed
return structured_output #.model_dump() or {}
# 에러 상태 처리
if response.status == "failed":
error_code = getattr(response.error, 'code', None) if response.error else None
error_message = getattr(response.error, 'message', None) if response.error else None
logger.warning(f"[ChatgptService] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
last_error = ChatGPTResponseError(response.status, error_code, error_message)
elif response.status == "incomplete":
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
logger.warning(f"[ChatgptService] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
else:
# cancelled, queued, in_progress 등 예상치 못한 상태
logger.warning(f"[ChatgptService] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
# 마지막 시도가 아니면 재시도
if attempt < self.max_retries:
logger.info(f"[ChatgptService] Retrying request...")
# 모든 재시도 실패
logger.error(f"[ChatgptService] All retries exhausted. Last error: {last_error}")
raise last_error
async def generate_structured_output(
self,
prompt : Prompt,
input_data : dict,
) -> BaseModel:
prompt_text = prompt.build_prompt(input_data)
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
# GPT API 호출
#response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
response = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model)
return response

View File

@ -31,13 +31,11 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
import copy import copy
import time import time
from enum import StrEnum
from typing import Literal from typing import Literal
import traceback
import httpx import httpx
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.prompts.schemas.image import SpaceType,Subject,Camera,MotionRecommended,NarrativePhase
from config import apikey_settings, creatomate_settings, recovery_settings from config import apikey_settings, creatomate_settings, recovery_settings
# 로거 설정 # 로거 설정
@ -222,28 +220,6 @@ autotext_template_h_1 = {
"stroke_color": "#333333", "stroke_color": "#333333",
"stroke_width": "0.2 vmin" "stroke_width": "0.2 vmin"
} }
DVST0001 = "75161273-0422-4771-adeb-816bd7263fb0"
DVST0002 = "c68cf750-bc40-485a-a2c5-3f9fe301e386"
DVST0003 = "e1fb5b00-1f02-4f63-99fa-7524b433ba47"
DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98"
DHST0002 = "3f194cc7-464e-4581-9db2-179d42d3e40f"
DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d"
DVST0001T = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
HST_LIST = [DHST0001,DHST0002,DHST0003]
VST_LIST = [DVST0001,DVST0002,DVST0003, DVST0001T]
SCENE_TRACK = 1
AUDIO_TRACK = 2
SUBTITLE_TRACK = 3
KEYWORD_TRACK = 4
def select_template(orientation:OrientationType):
if orientation == "horizontal":
return DHST0001
elif orientation == "vertical":
return DVST0001T
else:
raise
async def get_shared_client() -> httpx.AsyncClient: async def get_shared_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
@ -288,10 +264,23 @@ class CreatomateService:
BASE_URL = "https://api.creatomate.com" BASE_URL = "https://api.creatomate.com"
# 템플릿 설정 (config에서 가져옴)
TEMPLATE_CONFIG = {
"horizontal": {
"template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
"duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL,
},
"vertical": {
"template_id": creatomate_settings.TEMPLATE_ID_VERTICAL,
"duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL,
},
}
def __init__( def __init__(
self, self,
api_key: str | None = None, api_key: str | None = None,
orientation: OrientationType = "vertical" orientation: OrientationType = "vertical",
target_duration: float | None = None,
): ):
""" """
Args: Args:
@ -305,7 +294,14 @@ class CreatomateService:
self.orientation = orientation self.orientation = orientation
# orientation에 따른 템플릿 설정 가져오기 # orientation에 따른 템플릿 설정 가져오기
self.template_id = select_template(orientation) config = self.TEMPLATE_CONFIG.get(
orientation, self.TEMPLATE_CONFIG["vertical"]
)
self.template_id = config["template_id"]
self.target_duration = (
target_duration if target_duration is not None else config["duration"]
)
self.headers = { self.headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
@ -402,6 +398,14 @@ class CreatomateService:
return copy.deepcopy(data) return copy.deepcopy(data)
# 하위 호환성을 위한 별칭 (deprecated)
async def get_one_template_data_async(self, template_id: str) -> dict:
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
Deprecated: get_one_template_data() 사용하세요.
"""
return await self.get_one_template_data(template_id)
def parse_template_component_name(self, template_source: list) -> dict: def parse_template_component_name(self, template_source: list) -> dict:
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다.""" """템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
@ -428,112 +432,52 @@ class CreatomateService:
result.update(result_element_dict) result.update(result_element_dict)
return result return result
async def parse_template_name_tag(resource_name : str) -> list:
tag_list = []
tag_list = resource_name.split("_")
return tag_list
def counting_component(
self,
template : dict,
target_template_type : str
) -> list:
source_elements = template["source"]["elements"]
template_component_data = self.parse_template_component_name(source_elements)
count = 0
for _, (_, template_type) in enumerate(template_component_data.items()): async def template_connect_resource_blackbox(
if template_type == target_template_type:
count += 1
return count
def template_matching_taged_image(
self, self,
template : dict, template_id: str,
taged_image_list : list, # [{"image_name" : str , "image_tag" : dict}] image_url_list: list[str],
lyric: str,
music_url: str, music_url: str,
address : str, address: str = None
duplicate : bool = False ) -> dict:
) -> list: """템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
source_elements = template["source"]["elements"]
template_component_data = self.parse_template_component_name(source_elements)
Note:
- 이미지는 순차적으로 집어넣기
- 가사는 개행마다 텍스트 삽입
- Template에 audio-music 항목이 있어야
"""
template_data = await self.get_one_template_data(template_id)
template_component_data = self.parse_template_component_name(
template_data["source"]["elements"]
)
lyric = lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {} modifications = {}
for slot_idx, (template_component_name, template_type) in enumerate(template_component_data.items()): for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type: match template_type:
case "image": case "image":
image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name) modifications[template_component_name] = image_url_list[
maximum_idx = image_score_list.index(max(image_score_list)) idx % len(image_url_list)
if duplicate: ]
selected = taged_image_list[maximum_idx]
else:
selected = taged_image_list.pop(maximum_idx)
image_name = selected["image_url"]
modifications[template_component_name] =image_name
pass
case "text": case "text":
if "address_input" in template_component_name: if "address_input" in template_component_name:
modifications[template_component_name] = address modifications[template_component_name] = address
modifications["audio-music"] = music_url modifications["audio-music"] = music_url
return modifications return modifications
def calculate_image_slot_score_multi(self, taged_image_list : list[dict], slot_name : str):
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
slot_tag_dict = self.parse_slot_name_to_tag(slot_name)
image_score_list = [0] * len(image_tag_list)
for slot_tag_cate, slot_tag_item in slot_tag_dict.items():
if slot_tag_cate == "narrative_preference":
slot_tag_narrative = slot_tag_item
continue
match slot_tag_cate:
case "space_type":
weight = 2
case "subject" :
weight = 2
case "camera":
weight = 1
case "motion_recommended" :
weight = 0.5
case _:
raise
for idx, image_tag in enumerate(image_tag_list):
if slot_tag_item.value in image_tag[slot_tag_cate]: #collect!
image_score_list[idx] += weight
for idx, image_tag in enumerate(image_tag_list):
image_narrative_score = image_tag["narrative_preference"][slot_tag_narrative]
image_score_list[idx] = image_score_list[idx] * image_narrative_score
return image_score_list
def parse_slot_name_to_tag(self, slot_name : str) -> dict[str, StrEnum]:
tag_list = slot_name.split("-")
space_type = SpaceType(tag_list[0])
subject = Subject(tag_list[1])
camera = Camera(tag_list[2])
motion = MotionRecommended(tag_list[3])
narrative = NarrativePhase(tag_list[4])
tag_dict = {
"space_type" : space_type,
"subject" : subject,
"camera" : camera,
"motion_recommended" : motion,
"narrative_preference" : narrative,
}
return tag_dict
def elements_connect_resource_blackbox( def elements_connect_resource_blackbox(
self, self,
elements: list, elements: list,
image_url_list: list[str], image_url_list: list[str],
lyric: str,
music_url: str, music_url: str,
address: str = None address: str = None
) -> dict: ) -> dict:
@ -729,6 +673,14 @@ class CreatomateService:
original_response={"last_error": str(last_error)}, original_response={"last_error": str(last_error)},
) )
# 하위 호환성을 위한 별칭 (deprecated)
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
Deprecated: make_creatomate_custom_call() 사용하세요.
"""
return await self.make_creatomate_custom_call(source)
async def get_render_status(self, render_id: str) -> dict: async def get_render_status(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다. """렌더링 작업의 상태를 조회합니다.
@ -752,58 +704,47 @@ class CreatomateService:
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
# 하위 호환성을 위한 별칭 (deprecated)
async def get_render_status_async(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다.
Deprecated: get_render_status() 사용하세요.
"""
return await self.get_render_status(render_id)
def calc_scene_duration(self, template: dict) -> float: def calc_scene_duration(self, template: dict) -> float:
"""템플릿의 전체 장면 duration을 계산합니다.""" """템플릿의 전체 장면 duration을 계산합니다."""
total_template_duration = 0.0 total_template_duration = 0.0
track_maximum_duration = {
SCENE_TRACK : 0,
SUBTITLE_TRACK : 0,
KEYWORD_TRACK : 0
}
for elem in template["source"]["elements"]: for elem in template["source"]["elements"]:
try: try:
if elem["track"] not in track_maximum_duration: if elem["type"] == "audio":
continue continue
if "time" not in elem or elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음 total_template_duration += elem["duration"]
track_maximum_duration[elem["track"]] += elem["duration"] if "animations" not in elem:
continue
if "animations" not in elem: for animation in elem["animations"]:
continue assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
for animation in elem["animations"]: if animation["transition"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 total_template_duration -= animation["duration"]
if "transition" in animation and animation["transition"]:
track_maximum_duration[elem["track"]] -= animation["duration"]
else:
track_maximum_duration[elem["track"]] = max(track_maximum_duration[elem["track"]], elem["time"] + elem["duration"])
except Exception as e: except Exception as e:
logger.debug(traceback.format_exc())
logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}") logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}")
total_template_duration = max(track_maximum_duration.values())
return total_template_duration return total_template_duration
def extend_template_duration(self, template: dict, target_duration: float) -> dict: def extend_template_duration(self, template: dict, target_duration: float) -> dict:
"""템플릿의 duration을 target_duration으로 확장합니다.""" """템플릿의 duration을 target_duration으로 확장합니다."""
# template["duration"] = target_duration + 0.5 # 늘린것보단 짧게 template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
# target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것 target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
total_template_duration = self.calc_scene_duration(template) total_template_duration = self.calc_scene_duration(template)
extend_rate = target_duration / total_template_duration extend_rate = target_duration / total_template_duration
new_template = copy.deepcopy(template) new_template = copy.deepcopy(template)
for elem in new_template["source"]["elements"]: for elem in new_template["source"]["elements"]:
try: try:
# if elem["type"] == "audio": if elem["type"] == "audio":
# continue
if elem["track"] == AUDIO_TRACK : # audio track은 패스
continue continue
elem["duration"] = elem["duration"] * extend_rate
if "time" in elem:
elem["time"] = elem["time"] * extend_rate
if "duration" in elem:
elem["duration"] = elem["duration"] * extend_rate
if "animations" not in elem: if "animations" not in elem:
continue continue
for animation in elem["animations"]: for animation in elem["animations"]:
@ -844,25 +785,4 @@ class CreatomateService:
return autotext_template_v_1 return autotext_template_v_1
case "horizontal": case "horizontal":
return autotext_template_h_1 return autotext_template_h_1
def extract_text_format_from_template(self, template:dict):
keyword_list = []
subtitle_list = []
for elem in template["source"]["elements"]:
try: #최상위 내 텍스트만 검사
if elem["type"] == "text":
if elem["track"] == SUBTITLE_TRACK:
subtitle_list.append(elem["name"])
elif elem["track"] == KEYWORD_TRACK:
keyword_list.append(elem["name"])
except Exception as e:
logger.error(
f"[extend_template_duration] Error processing element: {elem}, {e}"
)
try:
assert(len(keyword_list)==len(subtitle_list))
except Exception as E:
logger.error("this template does not have same amount of keyword and subtitle.")
pitching_list = keyword_list + subtitle_list
return pitching_list

376
app/utils/facebook_oauth.py Normal file
View File

@ -0,0 +1,376 @@
"""
Facebook OAuth 2.0 API 클라이언트
Facebook Graph API를 통한 OAuth 2.0 인증 흐름을 처리하는 클라이언트입니다.
인증 흐름:
1. get_authorization_url() Facebook 로그인 페이지 URL 획득
2. 사용자가 Facebook에서 로그인 인가 코드(code) 발급
3. get_access_token()으로 인가 코드를 단기 액세스 토큰으로 교환
4. exchange_long_lived_token()으로 단기 토큰을 장기 토큰( 60)으로 교환
5. get_user_info() 사용자 정보 조회
6. get_user_pages() 관리 페이지 목록 조회 (선택)
Example:
```python
client = FacebookOAuthClient()
auth_url = client.get_authorization_url(state="csrf_token")
token_data = await client.get_access_token(code="auth_code")
long_token = await client.exchange_long_lived_token(token_data["access_token"])
user_info = await client.get_user_info(long_token["access_token"])
```
"""
from urllib.parse import urlencode
import httpx
from fastapi import HTTPException, status
from app.utils.logger import get_logger
from config import facebook_settings
logger = get_logger(__name__)
# =============================================================================
# Facebook OAuth 예외 클래스 정의
# =============================================================================
class FacebookOAuthException(HTTPException):
"""Facebook OAuth 관련 기본 예외"""
def __init__(self, status_code: int, code: str, message: str):
super().__init__(status_code=status_code, detail={"code": code, "message": message})
class FacebookAuthFailedError(FacebookOAuthException):
"""Facebook 인증 실패"""
def __init__(self, message: str = "Facebook 인증에 실패했습니다."):
super().__init__(status.HTTP_400_BAD_REQUEST, "FACEBOOK_AUTH_FAILED", message)
class FacebookAPIError(FacebookOAuthException):
"""Facebook API 호출 오류"""
def __init__(self, message: str = "Facebook API 호출 중 오류가 발생했습니다."):
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "FACEBOOK_API_ERROR", message)
class FacebookTokenExpiredError(FacebookOAuthException):
"""Facebook 토큰 만료"""
def __init__(self, message: str = "Facebook 토큰이 만료되었습니다. 재연동이 필요합니다."):
super().__init__(status.HTTP_401_UNAUTHORIZED, "FACEBOOK_TOKEN_EXPIRED", message)
class FacebookOAuthClient:
"""
Facebook OAuth 2.0 API 클라이언트
Facebook Graph API를 통한 OAuth 인증 흐름을 처리합니다.
모든 설정값은 config.py의 FacebookSettings에서 로드됩니다.
인증 흐름:
1. get_authorization_url() Facebook 로그인 페이지 URL 생성
2. get_access_token() 인가 코드를 단기 토큰으로 교환
3. exchange_long_lived_token() 단기 토큰을 장기 토큰(~60)으로 교환
4. get_user_info() 사용자 프로필 조회
5. get_user_pages() 관리 페이지 목록 조회
"""
# Facebook OAuth/Graph API URL 템플릿
AUTH_URL_TEMPLATE = "https://www.facebook.com/{version}/dialog/oauth"
TOKEN_URL_TEMPLATE = "https://graph.facebook.com/{version}/oauth/access_token"
USER_INFO_URL_TEMPLATE = "https://graph.facebook.com/{version}/me"
PAGES_URL_TEMPLATE = "https://graph.facebook.com/{version}/{user_id}/accounts"
def __init__(self) -> None:
# FacebookSettings에서 설정값 로드
self.client_id = facebook_settings.FACEBOOK_APP_ID
self.client_secret = facebook_settings.FACEBOOK_APP_SECRET
self.redirect_uri = facebook_settings.FACEBOOK_REDIRECT_URI
self.api_version = facebook_settings.FACEBOOK_GRAPH_API_VERSION
self.scope = facebook_settings.FACEBOOK_OAUTH_SCOPE
logger.debug(
f"[FACEBOOK] OAuth 클라이언트 초기화 - "
f"api_version: {self.api_version}, redirect_uri: {self.redirect_uri}"
)
def get_authorization_url(self, state: str) -> str:
"""
Facebook 로그인 페이지 URL 생성
Args:
state: CSRF 방지용 state 토큰
Returns:
Facebook OAuth 인증 페이지 URL
"""
# Facebook 인증 페이지 URL 조합
base_url = self.AUTH_URL_TEMPLATE.format(version=self.api_version)
params = {
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"state": state,
"scope": self.scope,
"response_type": "code",
}
auth_url = f"{base_url}?{urlencode(params)}"
logger.info(f"[FACEBOOK] 인증 URL 생성 - redirect_uri: {self.redirect_uri}")
logger.debug(f"[FACEBOOK] 인증 URL 상세 - state: {state[:20]}..., scope: {self.scope}")
return auth_url
async def get_access_token(self, code: str) -> dict:
"""
인가 코드를 단기 액세스 토큰으로 교환
Args:
code: Facebook 로그인 발급받은 인가 코드
Returns:
dict: {access_token, token_type, expires_in}
Raises:
FacebookAuthFailedError: 토큰 발급 실패
FacebookAPIError: API 호출 오류
"""
logger.info(f"[FACEBOOK] 액세스 토큰 요청 시작 - code: {code[:20]}...")
# Facebook Graph API - 인가 코드를 액세스 토큰으로 교환
token_url = self.TOKEN_URL_TEMPLATE.format(version=self.api_version)
params = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"redirect_uri": self.redirect_uri,
}
try:
async with httpx.AsyncClient() as client:
logger.debug(f"[FACEBOOK] 토큰 요청 URL: {token_url}")
response = await client.get(token_url, params=params)
result = response.json()
logger.debug(f"[FACEBOOK] 토큰 응답 상태 - status: {response.status_code}")
# 에러 응답 처리
if "error" in result:
error_msg = result.get("error", {})
error_message = error_msg.get("message", "알 수 없는 오류")
logger.error(
f"[FACEBOOK] 토큰 발급 실패 - "
f"type: {error_msg.get('type')}, message: {error_message}"
)
raise FacebookAuthFailedError(f"Facebook 토큰 발급 실패: {error_message}")
logger.info("[FACEBOOK] 단기 액세스 토큰 발급 성공")
logger.debug(
f"[FACEBOOK] 토큰 정보 - "
f"token_type: {result.get('token_type')}, "
f"expires_in: {result.get('expires_in')}"
)
return result
except FacebookAuthFailedError:
raise
except Exception as e:
logger.error(f"[FACEBOOK] API 호출 오류 - error: {str(e)}")
raise FacebookAPIError(f"Facebook API 호출 중 오류 발생: {str(e)}")
async def exchange_long_lived_token(self, short_lived_token: str) -> dict:
"""
단기 토큰을 장기 토큰으로 교환 ( 60 유효)
Args:
short_lived_token: 단기 액세스 토큰
Returns:
dict: {access_token, token_type, expires_in}
Raises:
FacebookAuthFailedError: 토큰 교환 실패
FacebookAPIError: API 호출 오류
"""
logger.info("[FACEBOOK] 장기 토큰 교환 시작")
# Facebook Graph API - 단기 토큰을 장기 토큰으로 교환
token_url = self.TOKEN_URL_TEMPLATE.format(version=self.api_version)
params = {
"grant_type": "fb_exchange_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"fb_exchange_token": short_lived_token,
}
try:
async with httpx.AsyncClient() as client:
logger.debug(f"[FACEBOOK] 장기 토큰 교환 URL: {token_url}")
response = await client.get(token_url, params=params)
result = response.json()
logger.debug(f"[FACEBOOK] 장기 토큰 교환 응답 상태 - status: {response.status_code}")
# 에러 응답 처리
if "error" in result:
error_msg = result.get("error", {})
error_message = error_msg.get("message", "알 수 없는 오류")
logger.error(
f"[FACEBOOK] 장기 토큰 교환 실패 - "
f"type: {error_msg.get('type')}, message: {error_message}"
)
raise FacebookAuthFailedError(f"Facebook 장기 토큰 교환 실패: {error_message}")
expires_in = result.get("expires_in", 0)
logger.info(f"[FACEBOOK] 장기 토큰 교환 성공 - expires_in: {expires_in}초 (약 {expires_in // 86400}일)")
return result
except FacebookAuthFailedError:
raise
except Exception as e:
logger.error(f"[FACEBOOK] API 호출 오류 - error: {str(e)}")
raise FacebookAPIError(f"Facebook API 호출 중 오류 발생: {str(e)}")
async def get_user_info(self, access_token: str) -> dict:
"""
액세스 토큰으로 사용자 정보 조회
Args:
access_token: Facebook 액세스 토큰
Returns:
dict: {id, name, email, picture}
Raises:
FacebookAuthFailedError: 사용자 정보 조회 실패
FacebookAPIError: API 호출 오류
"""
logger.info("[FACEBOOK] 사용자 정보 조회 시작")
# Facebook Graph API - 사용자 프로필 조회
user_info_url = self.USER_INFO_URL_TEMPLATE.format(version=self.api_version)
params = {
"fields": "id,name,email,picture",
"access_token": access_token,
}
try:
async with httpx.AsyncClient() as client:
logger.debug(f"[FACEBOOK] 사용자 정보 요청 URL: {user_info_url}")
response = await client.get(user_info_url, params=params)
result = response.json()
logger.debug(f"[FACEBOOK] 사용자 정보 응답 상태 - status: {response.status_code}")
# 에러 응답 처리
if "error" in result:
error_msg = result.get("error", {})
error_message = error_msg.get("message", "알 수 없는 오류")
error_code = error_msg.get("code")
logger.error(
f"[FACEBOOK] 사용자 정보 조회 실패 - "
f"code: {error_code}, message: {error_message}"
)
# 토큰 만료 에러 (code=190)
if error_code == 190:
raise FacebookTokenExpiredError()
raise FacebookAuthFailedError(f"Facebook 사용자 정보 조회 실패: {error_message}")
# 필수 필드(id) 확인
if "id" not in result:
logger.error(f"[FACEBOOK] 사용자 정보에 id 없음 - response: {result}")
raise FacebookAuthFailedError("Facebook 사용자 정보를 가져올 수 없습니다.")
logger.info(f"[FACEBOOK] 사용자 정보 조회 성공 - id: {result.get('id')}")
logger.debug(
f"[FACEBOOK] 사용자 상세 정보 - "
f"name: {result.get('name')}, email: {result.get('email')}"
)
return result
except (FacebookAuthFailedError, FacebookTokenExpiredError):
raise
except Exception as e:
logger.error(f"[FACEBOOK] API 호출 오류 - error: {str(e)}")
raise FacebookAPIError(f"Facebook API 호출 중 오류 발생: {str(e)}")
async def get_user_pages(self, user_id: str, access_token: str) -> list[dict]:
"""
사용자가 관리하는 Facebook 페이지 목록 조회
Args:
user_id: Facebook 사용자 ID
access_token: Facebook 액세스 토큰
Returns:
list[dict]: [{id, name, access_token, category}, ...]
Raises:
FacebookAPIError: API 호출 오류
"""
logger.info(f"[FACEBOOK] 페이지 목록 조회 시작 - user_id: {user_id}")
# Facebook Graph API - 사용자 관리 페이지 목록 조회
pages_url = self.PAGES_URL_TEMPLATE.format(
version=self.api_version, user_id=user_id
)
params = {"access_token": access_token}
try:
async with httpx.AsyncClient() as client:
logger.debug(f"[FACEBOOK] 페이지 목록 요청 URL: {pages_url}")
response = await client.get(pages_url, params=params)
result = response.json()
logger.debug(f"[FACEBOOK] 페이지 목록 응답 상태 - status: {response.status_code}")
# 에러 응답 처리
if "error" in result:
error_msg = result.get("error", {})
error_message = error_msg.get("message", "알 수 없는 오류")
logger.error(f"[FACEBOOK] 페이지 목록 조회 실패 - message: {error_message}")
raise FacebookAPIError(f"Facebook 페이지 목록 조회 실패: {error_message}")
pages = result.get("data", [])
logger.info(f"[FACEBOOK] 페이지 목록 조회 성공 - 페이지 수: {len(pages)}")
logger.debug(
f"[FACEBOOK] 페이지 상세 - "
f"pages: {[{'id': p.get('id'), 'name': p.get('name')} for p in pages]}"
)
return pages
except FacebookAPIError:
raise
except Exception as e:
logger.error(f"[FACEBOOK] API 호출 오류 - error: {str(e)}")
raise FacebookAPIError(f"Facebook API 호출 중 오류 발생: {str(e)}")
@staticmethod
def is_token_expired(token_expires_at) -> bool:
"""
토큰 만료 여부 확인 (만료 7 전부터 True 반환)
Args:
token_expires_at: 토큰 만료 일시 (aware datetime)
Returns:
True: 토큰이 만료되었거나 7 이내 만료 예정
"""
from datetime import timedelta
from app.utils.timezone import now
# 만료 7일 전부터 재연동 필요로 판단 (aware datetime 사용)
threshold = now() + timedelta(days=7)
is_expired = token_expires_at <= threshold
logger.debug(
f"[FACEBOOK] 토큰 만료 확인 - "
f"expires_at: {token_expires_at}, threshold: {threshold}, expired: {is_expired}"
)
return is_expired

View File

@ -16,10 +16,6 @@ class GraphQLException(Exception):
"""GraphQL 요청 실패 시 발생하는 예외""" """GraphQL 요청 실패 시 발생하는 예외"""
pass pass
class URLNotFoundException(Exception):
"""Place ID 발견 불가능 시 발생하는 예외"""
pass
class CrawlingTimeoutException(Exception): class CrawlingTimeoutException(Exception):
"""크롤링 타임아웃 시 발생하는 예외""" """크롤링 타임아웃 시 발생하는 예외"""
@ -90,28 +86,34 @@ query getAccommodation($id: String!, $deviceType: String) {
async with session.get(self.url) as response: async with session.get(self.url) as response:
self.url = str(response.url) self.url = str(response.url)
else: else:
raise URLNotFoundException("This URL does not contain a place ID") raise GraphQLException("This URL does not contain a place ID")
match = re.search(place_pattern, self.url) match = re.search(place_pattern, self.url)
if not match: if not match:
raise URLNotFoundException("Failed to parse place ID from URL") raise GraphQLException("Failed to parse place ID from URL")
return match[1] return match[1]
async def scrap(self): async def scrap(self):
place_id = await self.parse_url() try:
data = await self._call_get_accommodation(place_id) place_id = await self.parse_url()
self.rawdata = data data = await self._call_get_accommodation(place_id)
fac_data = await self._get_facility_string(place_id) self.rawdata = data
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것. fac_data = await self._get_facility_string(place_id)
self.place_id = self.data_source_identifier + place_id # Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
self.rawdata["facilities"] = fac_data self.place_id = self.data_source_identifier + place_id
self.image_link_list = [ self.rawdata["facilities"] = fac_data
nv_image["origin"] self.image_link_list = [
for nv_image in data["data"]["business"]["images"]["images"] nv_image["origin"]
] for nv_image in data["data"]["business"]["images"]["images"]
self.base_info = data["data"]["business"]["base"] ]
self.facility_info = fac_data self.base_info = data["data"]["business"]["base"]
self.scrap_type = "GraphQL" self.facility_info = fac_data
self.scrap_type = "GraphQL"
except GraphQLException:
logger.debug("GraphQL failed, fallback to Playwright")
self.scrap_type = "Playwright"
pass # 나중에 pw 이용한 crawling으로 fallback 추가
return return

View File

@ -1,191 +0,0 @@
import json
import re
from pydantic import BaseModel
from typing import List, Optional
from openai import AsyncOpenAI
from app.utils.logger import get_logger
from config import apikey_settings, recovery_settings
from app.utils.prompts.prompts import Prompt
# 로거 설정
logger = get_logger("chatgpt")
class ChatGPTResponseError(Exception):
"""ChatGPT API 응답 에러"""
def __init__(self, status: str, error_code: str = None, error_message: str = None):
self.status = status
self.error_code = error_code
self.error_message = error_message
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
class ChatgptService:
"""ChatGPT API 서비스 클래스
"""
model_type : str
def __init__(self, model_type:str = "gpt", timeout: float = None):
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
self.model_type = model_type
match model_type:
case "gpt":
self.client = AsyncOpenAI(
api_key=apikey_settings.CHATGPT_API_KEY,
timeout=self.timeout
)
case "gemini":
self.client = AsyncOpenAI(
api_key=apikey_settings.GEMINI_API_KEY,
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
timeout=self.timeout
)
case _:
raise NotImplementedError(f"Unknown Provider : {model_type}")
async def _call_pydantic_output(
self,
prompt : str,
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
model : str,
img_url : str,
image_detail_high : bool) -> BaseModel:
content = []
if img_url:
content.append({
"type" : "input_image",
"image_url" : img_url,
"detail": "high" if image_detail_high else "low"
})
content.append({
"type": "input_text",
"text": prompt}
)
last_error = None
for attempt in range(self.max_retries + 1):
response = await self.client.responses.parse(
model=model,
input=[{"role": "user", "content": content}],
text_format=output_format
)
# Response 디버그 로깅
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
logger.debug(f"[ChatgptService({self.model_type})] Response status: {response.status}")
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
if response.status == "completed":
logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
structured_output = response.output_parsed
return structured_output #.model_dump() or {}
# 에러 상태 처리
if response.status == "failed":
error_code = getattr(response.error, 'code', None) if response.error else None
error_message = getattr(response.error, 'message', None) if response.error else None
logger.warning(f"[ChatgptService({self.model_type})] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
last_error = ChatGPTResponseError(response.status, error_code, error_message)
elif response.status == "incomplete":
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
logger.warning(f"[ChatgptService({self.model_type})] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
else:
# cancelled, queued, in_progress 등 예상치 못한 상태
logger.warning(f"[ChatgptService({self.model_type})] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
# 마지막 시도가 아니면 재시도
if attempt < self.max_retries:
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
# 모든 재시도 실패
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
raise last_error
async def _call_pydantic_output_chat_completion( # alter version
self,
prompt : str,
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
model : str,
img_url : str,
image_detail_high : bool) -> BaseModel:
content = []
if img_url:
content.append({
"type": "image_url",
"image_url": {
"url": img_url,
"detail": "high" if image_detail_high else "low"
}
})
content.append({
"type": "text",
"text": prompt
})
last_error = None
for attempt in range(self.max_retries + 1):
response = await self.client.beta.chat.completions.parse(
model=model,
messages=[{"role": "user", "content": content}],
response_format=output_format
)
# Response 디버그 로깅
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
logger.debug(f"[ChatgptService({self.model_type})] Response finish_reason: {response.id}")
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
choice = response.choices[0]
finish_reason = choice.finish_reason
if finish_reason == "stop":
output_text = choice.message.content or ""
logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {output_text[:200]}..." if len(output_text) > 200 else f"[ChatgptService] Response output_text: {output_text}")
return choice.message.parsed
elif finish_reason == "length":
logger.warning(f"[ChatgptService({self.model_type})] Response incomplete - token limit reached (attempt {attempt + 1}/{self.max_retries + 1})")
last_error = ChatGPTResponseError("incomplete", finish_reason, "Response incomplete: max tokens reached")
elif finish_reason == "content_filter":
logger.warning(f"[ChatgptService({self.model_type})] Response blocked by content filter (attempt {attempt + 1}/{self.max_retries + 1})")
last_error = ChatGPTResponseError("failed", finish_reason, "Response blocked by content filter")
else:
logger.warning(f"[ChatgptService({self.model_type})] Unexpected finish_reason (attempt {attempt + 1}/{self.max_retries + 1}): {finish_reason}")
last_error = ChatGPTResponseError("failed", finish_reason, f"Unexpected finish_reason: {finish_reason}")
# 마지막 시도가 아니면 재시도
if attempt < self.max_retries:
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
# 모든 재시도 실패
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
raise last_error
async def generate_structured_output(
self,
prompt : Prompt,
input_data : dict,
img_url : Optional[str] = None,
img_detail_high : bool = False,
silent : bool = False
) -> BaseModel:
prompt_text = prompt.build_prompt(input_data, silent)
logger.debug(f"[ChatgptService({self.model_type})] Generated Prompt (length: {len(prompt_text)})")
if not silent:
logger.info(f"[ChatgptService({self.model_type})] Starting GPT request with structured output with model: {prompt.prompt_model}")
# GPT API 호출
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
return parsed

View File

@ -1,89 +1,65 @@
import gspread import os, json
from pydantic import BaseModel from pydantic import BaseModel
from google.oauth2.service_account import Credentials
from config import prompt_settings from config import prompt_settings
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.prompts.schemas import * from app.utils.prompts.schemas import *
from functools import lru_cache
logger = get_logger("prompt") logger = get_logger("prompt")
_SCOPES = [
"https://www.googleapis.com/auth/spreadsheets.readonly",
"https://www.googleapis.com/auth/drive.readonly"
]
class Prompt(): class Prompt():
sheet_name: str prompt_template_path : str #프롬프트 경로
prompt_template: str prompt_template : str # fstring 포맷
prompt_model: str prompt_model : str
prompt_input_class = BaseModel prompt_input_class = BaseModel # pydantic class 자체를(instance 아님) 변수로 가짐
prompt_output_class = BaseModel prompt_output_class = BaseModel
def __init__(self, sheet_name, prompt_input_class, prompt_output_class): def __init__(self, prompt_template_path, prompt_input_class, prompt_output_class, prompt_model):
self.sheet_name = sheet_name self.prompt_template_path = prompt_template_path
self.prompt_input_class = prompt_input_class self.prompt_input_class = prompt_input_class
self.prompt_output_class = prompt_output_class self.prompt_output_class = prompt_output_class
self.prompt_template, self.prompt_model = self._read_from_sheets() self.prompt_template = self.read_prompt()
self.prompt_model = prompt_model
def _read_from_sheets(self) -> tuple[str, str]:
creds = Credentials.from_service_account_file(
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
)
gc = gspread.authorize(creds)
ws = gc.open_by_key(prompt_settings.PROMPT_SPREADSHEET).worksheet(self.sheet_name)
model = ws.cell(2, 2).value
input_text = ws.cell(3, 2).value
return input_text, model
def _reload_prompt(self): def _reload_prompt(self):
self.prompt_template, self.prompt_model = self._read_from_sheets() self.prompt_template = self.read_prompt()
def build_prompt(self, input_data:dict, silent:bool = False) -> str: def read_prompt(self) -> tuple[str, dict]:
with open(self.prompt_template_path, "r") as fp:
prompt_template = fp.read()
return prompt_template
def build_prompt(self, input_data:dict) -> str:
verified_input = self.prompt_input_class(**input_data) verified_input = self.prompt_input_class(**input_data)
build_template = self.prompt_template build_template = self.prompt_template
build_template = build_template.format(**verified_input.model_dump()) build_template = build_template.format(**verified_input.model_dump())
if not silent: logger.debug(f"build_template: {build_template}")
logger.debug(f"build_template: {build_template}") logger.debug(f"input_data: {input_data}")
logger.debug(f"input_data: {input_data}")
return build_template return build_template
marketing_prompt = Prompt( marketing_prompt = Prompt(
sheet_name="marketing", prompt_template_path = os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_FILE_NAME),
prompt_input_class=MarketingPromptInput, prompt_input_class = MarketingPromptInput,
prompt_output_class=MarketingPromptOutput, prompt_output_class = MarketingPromptOutput,
prompt_model = prompt_settings.MARKETING_PROMPT_MODEL
) )
lyric_prompt = Prompt( lyric_prompt = Prompt(
sheet_name="lyric", prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYRIC_PROMPT_FILE_NAME),
prompt_input_class=LyricPromptInput, prompt_input_class = LyricPromptInput,
prompt_output_class=LyricPromptOutput, prompt_output_class = LyricPromptOutput,
prompt_model = prompt_settings.LYRIC_PROMPT_MODEL
) )
yt_upload_prompt = Prompt( yt_upload_prompt = Prompt(
sheet_name="yt_upload", prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.YOUTUBE_PROMPT_FILE_NAME),
prompt_input_class=YTUploadPromptInput, prompt_input_class = YTUploadPromptInput,
prompt_output_class=YTUploadPromptOutput, prompt_output_class = YTUploadPromptOutput,
prompt_model = prompt_settings.YOUTUBE_PROMPT_MODEL
) )
image_autotag_prompt = Prompt(
sheet_name="image_tag",
prompt_input_class=ImageTagPromptInput,
prompt_output_class=ImageTagPromptOutput,
)
@lru_cache()
def create_dynamic_subtitle_prompt(length: int) -> Prompt:
return Prompt(
sheet_name="subtitle",
prompt_input_class=SubtitlePromptInput,
prompt_output_class=SubtitlePromptOutput[length],
)
def reload_all_prompt(): def reload_all_prompt():
marketing_prompt._reload_prompt() marketing_prompt._reload_prompt()
lyric_prompt._reload_prompt() lyric_prompt._reload_prompt()
yt_upload_prompt._reload_prompt() yt_upload_prompt._reload_prompt()
image_autotag_prompt._reload_prompt()

View File

@ -1,5 +1,3 @@
from .lyric import LyricPromptInput, LyricPromptOutput from .lyric import LyricPromptInput, LyricPromptOutput
from .marketing import MarketingPromptInput, MarketingPromptOutput from .marketing import MarketingPromptInput, MarketingPromptOutput
from .youtube import YTUploadPromptInput, YTUploadPromptOutput from .youtube import YTUploadPromptInput, YTUploadPromptOutput
from .image import *
from .subtitle import SubtitlePromptInput, SubtitlePromptOutput

View File

@ -1,110 +0,0 @@
from pydantic import BaseModel, Field
from typing import List, Optional
from enum import StrEnum, auto
class SpaceType(StrEnum):
exterior_front = auto()
exterior_night = auto()
exterior_aerial = auto()
exterior_sign = auto()
garden = auto()
entrance = auto()
lobby = auto()
reception = auto()
hallway = auto()
bedroom = auto()
livingroom = auto()
kitchen = auto()
dining = auto()
room = auto()
bathroom = auto()
amenity = auto()
view_window = auto()
view_ocean = auto()
view_city = auto()
view_mountain = auto()
balcony = auto()
cafe = auto()
lounge = auto()
rooftop = auto()
pool = auto()
breakfast_hall = auto()
spa = auto()
fitness = auto()
bbq = auto()
terrace = auto()
glamping = auto()
neighborhood = auto()
landmark = auto()
detail_welcome = auto()
detail_beverage = auto()
detail_lighting = auto()
detail_decor = auto()
detail_tableware = auto()
class Subject(StrEnum):
empty_space = auto()
exterior_building = auto()
architecture_detail = auto()
decoration = auto()
furniture = auto()
food_dish = auto()
nature = auto()
signage = auto()
amenity_item = auto()
person = auto()
class Camera(StrEnum):
wide_angle = auto()
tight_crop = auto()
panoramic = auto()
symmetrical = auto()
leading_line = auto()
golden_hour = auto()
night_shot = auto()
high_contrast = auto()
low_light = auto()
drone_shot = auto()
has_face = auto()
class MotionRecommended(StrEnum):
static = auto()
slow_pan = auto()
slow_zoom_in = auto()
slow_zoom_out = auto()
walkthrough = auto()
dolly = auto()
class NarrativePhase(StrEnum):
intro = auto()
welcome = auto()
core = auto()
highlight = auto()
support = auto()
accent = auto()
class NarrativePreference(BaseModel):
intro: float = Field(..., description="첫인상 — 여기가 어디인가 | 장소의 정체성과 위치를 전달하는 이미지. 영상 첫 1~2초에 어떤 곳인지 즉시 인지시키는 역할. 건물 외관, 간판, 정원 등 **장소 자체를 보여주는** 컷")
welcome: float = Field(..., description="진입/환영 — 어떻게 들어가나 | 도착 후 내부로 들어가는 경험을 전달하는 이미지. 공간의 첫 분위기와 동선을 보여줘 들어가고 싶다는 기대감을 만드는 역할. **문을 열고 들어갔을 때 보이는** 컷.")
core: float = Field(..., description="핵심 가치 — 무엇을 경험하나 | **고객이 이 장소를 찾는 본질적 이유.** 이 이미지가 없으면 영상 자체가 성립하지 않음. 질문: 이 비즈니스에서 돈을 지불하는 대상이 뭔가? → 그 답이 core.")
highlight: float = Field(..., description="차별화 — 뭐가 특별한가 | **같은 카테고리의 경쟁사 대비 이곳을 선택하게 만드는 이유.** core가 왜 왔는가라면, highlight는 왜 **여기**인가에 대한 답.")
support: float = Field(..., description="보조/부대 — 그 외에 뭐가 있나 | 핵심은 아니지만 전체 경험을 풍성하게 하는 부가 요소. 없어도 영상은 성립하지만, 있으면 설득력이 올라감. **이것도 있어요** 라고 말하는 컷.")
accent: float = Field(..., description="감성/마무리 — 어떤 느낌인가 | 공간의 분위기와 톤을 전달하는 감성 디테일 컷. 직접적 정보 전달보다 **느낌과 무드**를 제공. 영상 사이사이에 삽입되어 완성도를 높이는 역할.")
# Input 정의
class ImageTagPromptInput(BaseModel):
img_url : str = Field(..., description="이미지 URL")
space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트")
subject: list[str] = Field(list(Subject), description="피사체 정보를 가지는 태그 리스트")
camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트")
motion_recommended: list[str] = Field(list(MotionRecommended), description="가능한 카메라 모션 리스트")
# Output 정의
class ImageTagPromptOutput(BaseModel):
#ad_avaliable : bool = Field(..., description="광고 영상 사용 가능 이미지 여부")
space_type: list[SpaceType] = Field(..., description="공간적 정보를 가지는 태그 리스트")
subject: list[Subject] = Field(..., description="피사체 정보를 가지는 태그 리스트")
camera: list[Camera] = Field(..., description="카메라 정보를 가지는 태그 리스트")
motion_recommended: list[MotionRecommended] = Field(..., description="가능한 카메라 모션 리스트")
narrative_preference: NarrativePreference = Field(..., description="이미지의 내러티브 상 점수")

View File

@ -7,11 +7,13 @@ class MarketingPromptInput(BaseModel):
region : str = Field(..., description = "마케팅 대상 지역") region : str = Field(..., description = "마케팅 대상 지역")
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
# Output 정의 # Output 정의
class BrandIdentity(BaseModel): class BrandIdentity(BaseModel):
location_feature_analysis: str = Field(..., description="입지 특성 분석 (80자 이상 150자 이하)", min_length = 80, max_length = 150) # min/max constraint는 현재 openai json schema 등에서 작동하지 않는다는 보고가 있음. location_feature_analysis: str = Field(..., description="입지 특성 분석 (80자 이상 150자 이하)", min_length = 80, max_length = 150) # min/max constraint는 현재 openai json schema 등에서 작동하지 않는다는 보고가 있음.
concept_scalability: str = Field(..., description="컨셉 확장성 (80자 이상 150자 이하)", min_length = 80, max_length = 150) concept_scalability: str = Field(..., description="컨셉 확장성 (80자 이상 150자 이하)", min_length = 80, max_length = 150)
class MarketPositioning(BaseModel): class MarketPositioning(BaseModel):
category_definition: str = Field(..., description="마케팅 카테고리") category_definition: str = Field(..., description="마케팅 카테고리")
core_value: str = Field(..., description="마케팅 포지션 핵심 가치") core_value: str = Field(..., description="마케팅 포지션 핵심 가치")
@ -20,12 +22,14 @@ class AgeRange(BaseModel):
min_age : int = Field(..., ge=0, le=100) min_age : int = Field(..., ge=0, le=100)
max_age : int = Field(..., ge=0, le=100) max_age : int = Field(..., ge=0, le=100)
class TargetPersona(BaseModel): class TargetPersona(BaseModel):
persona: str = Field(..., description="타겟 페르소나 이름/설명") persona: str = Field(..., description="타겟 페르소나 이름/설명")
age: AgeRange = Field(..., description="타겟 페르소나 나이대") age: AgeRange = Field(..., description="타겟 페르소나 나이대")
favor_target: List[str] = Field(..., description="페르소나의 선호 요소") favor_target: List[str] = Field(..., description="페르소나의 선호 요소")
decision_trigger: str = Field(..., description="구매 결정 트리거") decision_trigger: str = Field(..., description="구매 결정 트리거")
class SellingPoint(BaseModel): class SellingPoint(BaseModel):
english_category: str = Field(..., description="셀링포인트 카테고리(영문)") english_category: str = Field(..., description="셀링포인트 카테고리(영문)")
korean_category: str = Field(..., description="셀링포인트 카테고리(한글)") korean_category: str = Field(..., description="셀링포인트 카테고리(한글)")

View File

@ -1,31 +0,0 @@
from pydantic import BaseModel, create_model, Field
from typing import List, Optional
from functools import lru_cache
# Input 정의
class SubtitlePromptInput(BaseModel):
marketing_intelligence : str = Field(..., description="마케팅 인텔리전스 정보")
pitching_tag_list_string : str = Field(..., description="필요한 피칭 레이블 리스트 stringify")
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
#subtillecars :
# Output 정의
class PitchingOutput(BaseModel):
pitching_tag: str = Field(..., description="피칭 레이블")
pitching_data: str = Field(..., description = "피칭 내용물")
class SubtitlePromptOutput(BaseModel):
pitching_results: List[PitchingOutput] = Field(..., description = "피칭 리스트")
@classmethod
@lru_cache()
def __class_getitem__(cls, n: int):
return create_model(
cls.__name__,
pitching_results=(
List[PitchingOutput],
Field(..., min_length=n, max_length=n, description="피칭 리스트")
),
)

View File

@ -0,0 +1,64 @@
[Role & Objective]
Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.
Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.
The report must clearly explain what makes the property sellable, marketable, and scalable through content.
[INPUT]
- Business Name: {customer_name}
- Region: {region}
- Region Details: {detail_region_info}
[Core Analysis Requirements]
Analyze the property based on:
Location, concept, and nearby environment
Target customer behavior and reservation decision factors
Include:
- Target customer segments & personas
- Unique Selling Propositions (USPs)
- Competitive landscape (direct & indirect competitors)
- Market positioning
[Key Selling Point Structuring UI Optimized]
From the analysis above, extract the main Key Selling Points using the structure below.
Rules:
Focus only on factors that directly influence booking decisions
Each selling point must be concise and visually scannable
Language must be reusable for ads, short-form videos, and listing headlines
Avoid full sentences in descriptions; use short selling phrases
Do not provide in report
Output format:
[Category]
(Tag keyword 5~8 words, noun-based, UI oval-style)
One-line selling phrase (not a full sentence)
Limit:
5 to 8 Key Selling Points only
Do not provide in report
[Content & Automation Readiness Check]
Ensure that:
Each tag keyword can directly map to a content theme
Each selling phrase can be used as:
- Video hook
- Image headline
- Ad copy snippet
[Tag Generation Rules]
- Tags must include **only core keywords that can be directly used for viral video song lyrics**
- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind
- The number of tags must be **exactly 5**
- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited
- The following categories must be **balanced and all represented**:
1) **Location / Local context** (region name, neighborhood, travel context)
2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)
3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)
4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)
5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)
- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**
- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**
- The final output must strictly follow the JSON format below, with no additional text
"tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"]

View File

@ -0,0 +1,77 @@
[ROLE]
You are a content marketing expert, brand strategist, and creative songwriter
specializing in Korean pension / accommodation businesses.
You create lyrics strictly based on Brand & Marketing Intelligence analysis
and optimized for viral short-form video content.
Marketing Intelligence Report is background reference.
[INPUT]
Business Name: {customer_name}
Region: {region}
Region Details: {detail_region_info}
Brand & Marketing Intelligence Report: {marketing_intelligence_summary}
Output Language: {language}
[INTERNAL ANALYSIS DO NOT OUTPUT]
Internally analyze the following to guide all creative decisions:
- Core brand identity and positioning
- Emotional hooks derived from selling points
- Target audience lifestyle, desires, and travel motivation
- Regional atmosphere and symbolic imagery
- How the stay converts into “shareable moments”
- Which selling points must surface implicitly in lyrics
[LYRICS & MUSIC CREATION TASK]
Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:
- Original promotional lyrics
- Music attributes for AI music generation (Suno-compatible prompt)
The output must be designed for VIRAL DIGITAL CONTENT
(short-form video, reels, ads).
[LYRICS REQUIREMENTS]
Mandatory Inclusions:
- Business name
- Region name
- Promotion subject
- Promotional expressions including:
{promotional_expression_example}
Content Rules:
- Lyrics must be emotionally driven, not descriptive listings
- Selling points must be IMPLIED, not explained
- Must sound natural when sung
- Must feel like a lifestyle moment, not an advertisement
Tone & Style:
- Warm, emotional, and aspirational
- Trendy, viral-friendly phrasing
- Calm but memorable hooks
- Suitable for travel / stay-related content
[SONG & MUSIC ATTRIBUTES FOR SUNO PROMPT]
After the lyrics, generate a concise music prompt including:
Song mood (emotional keywords)
BPM range
Recommended genres (max 2)
Key musical motifs or instruments
Overall vibe (1 short sentence)
[CRITICAL LANGUAGE REQUIREMENT ABSOLUTE RULE]
ALL OUTPUT MUST BE 100% WRITTEN IN {language}.
no mixed languages
All names, places, and expressions must be in {language}
Any violation invalidates the entire output
[OUTPUT RULES STRICT]
{timing_rules}
No explanations
No headings
No bullet points
No analysis
No extra text
[FAILURE FORMAT]
If generation is impossible:
ERROR: Brief reason in English

View File

@ -0,0 +1,42 @@
# Role
Act as a Senior Brand Strategist and Marketing Data Analyst. Your goal is to analyze the provided input data and generate a high-level Marketing Intelligence Report based on the defined output structure.
# Input Data
* **Customer Name:** {customer_name}
* **Region:** {region}
* **Detail Region Info:** {detail_region_info}
# Output Rules
1. **Language:** All descriptive content must be written in **Korean (한국어)**.
2. **Terminology:** Use professional marketing terminology suitable for the hospitality and stay industry.
3. **Strict Selection for `selling_points.english_category` and `selling_points.korean_category`:** You must select the value for both category field in `selling_points` strictly from the following English - Korean set allowed list to ensure UI compatibility:
* `LOCATION` (입지 환경), `CONCEPT` (브랜드 컨셉), `PRIVACY` (프라이버시), `NIGHT MOOD` (야간 감성), `HEALING` (힐링 요소), `PHOTO SPOT` (포토 스팟), `SHORT GETAWAY` (숏브레이크), `HOSPITALITY` (서비스), `SWIMMING POOL` (수영장), `JACUZZI` (자쿠지), `BBQ PARTY` (바베큐), `FIRE PIT` (불멍), `GARDEN` (정원), `BREAKFAST` (조식), `KIDS FRIENDLY` (키즈 케어), `PET FRIENDLY` (애견 동반), `OCEAN VIEW` (오션뷰), `PRIVATE POOL` (개별 수영장), `OCEAN VIEW`, `PRIVATE POOL`.
---
# Instruction per Output Field (Mapping Logic)
### 1. brand_identity
* **`location_feature_analysis`**: Analyze the marketing advantages of the given `{region}` and `{detail_region_info}`. Explain why this specific location is attractive to travelers. summarize in 1-2 sentences. (e.g., proximity to nature, accessibility from Seoul, or unique local atmosphere).
* **`concept_scalability`**: Based on `{customer_name}`, analyze how the brand's core concept can expand into a total customer experience or additional services. summarize in 1-2 sentences.
### 2. market_positioning
* **`category_definition`**: Define a sharp, niche market category for this business (e.g., "Private Forest Cabin" or "Luxury Kids Pool Villa").
* **`core_value`**: Identify the single most compelling emotional or functional value that distinguishes `{customer_name}` from competitors.
### 3. target_persona
Generate a list of personas based on the following:
* **`persona`**: Provide a descriptive name and profile for the target group.
* **`age`**: Set `min_age` and `max_age` (Integer 0-100) that accurately reflects the segment.
* **`favor_target`**: List specific elements or vibes this persona prefers (e.g., "Minimalist interior", "Pet-friendly facilities").
* **`decision_trigger`**: Identify the specific "Hook" or facility that leads this persona to finalize a booking.
### 4. selling_points
Generate 5-8 selling points:
* **`english_category`**: Strictly use one keyword from the English allowed list provided in the Output Rules.
* **`korean category`**: Strictly use one keyword from the Korean allowed list provided in the Output Rules . It must be matched with english category.
* **`description`**: A short, punchy marketing phrase in Korean (15~30 characters).
* **`score`**: An integer (0-100) representing the strength of this feature based on the brand's potential.
### 5. target_keywords
* **`target_keywords`**: Provide a list of 10 highly relevant marketing keywords or hashtags for search engine optimization and social media targeting. Do not insert # in front of hashtag.

View File

@ -1,233 +0,0 @@
# System Prompt: 숙박 숏폼 자막 생성 (OpenAI Optimized)
You are a subtitle copywriter for hospitality short-form videos. You generate subtitle text AND layer names from marketing JSON data.
---
### RULES
1. NEVER copy JSON verbatim. ALWAYS rewrite into video-optimized copy.
2. NEVER invent facts not in the data. You MAY freely transform expressions.
3. Each scene = 1 subtitle + 1 keyword (a "Pair"). Same pair_id for both.
---
### LAYER NAME FORMAT (5-criteria)
```
(track_role)-(narrative_phase)-(content_type)-(tone)-(pair_id)
```
- Criteria separator: hyphen `-`
- Multi-word value: underscore `_`
- pair_id: 3-digit zero-padded (`001`~`999`)
Example: `subtitle-intro-hook_claim-aspirational-001`
---
### TAG VALUES
**track_role**: `subtitle` | `keyword`
**narrative_phase** (= emotion goal):
- `intro` → Curiosity (stop the scroll)
- `welcome` → Warmth
- `core` → Trust
- `highlight` → Desire (peak moment)
- `support` → Discovery
- `accent` → Belonging
- `cta` → Action
**content_type** → source mapping:
- `hook_claim` ← selling_points[0] or core_value
- `space_feature` ← selling_points[].description
- `emotion_cue` ← same source, sensory rewrite
- `brand_name` ← store_name (verbatim OK)
- `brand_address` ← detail_region_info (verbatim OK)
- `lifestyle_fit` ← target_persona[].favor_target
- `local_info` ← location_feature_analysis
- `target_tag` ← target_keywords[] as hashtags
- `availability` ← fixed: "지금 예약 가능"
- `cta_action` ← fixed: "예약하러 가기"
**tone**: `sensory` | `factual` | `empathic` | `aspirational` | `social_proof` | `urgent`
---
### SCENE STRUCTURE
**Anchors (FIXED — never remove):**
| Position | Phase | subtitle | keyword |
|---|---|---|---|
| First | intro | hook_claim | brand_name |
| Last-3 | support | brand_address | brand_name |
| Last-2 | accent | target_tag | lifestyle_fit |
| Last | cta | availability | cta_action |
**Middle (FLEXIBLE — fill by selling_points score desc):**
| Phase | subtitle | keyword |
|---|---|---|
| welcome | emotion_cue | space_feature |
| core | space_feature | emotion_cue |
| highlight | space_feature | emotion_cue |
| support(mid) | local_info | lifestyle_fit |
Default: 7 scenes. Fewer scenes → remove flexible slots only.
---
### TEXT SPECS
**subtitle**: 8~18 chars. Sentence fragment, conversational.
**keyword**: 2~6 chars. MUST follow Korean word-formation rules below.
---
### KEYWORD RULES (한국어 조어법 기반)
Keywords MUST follow one of these **permitted Korean patterns**. Any keyword that does not match a pattern below is INVALID.
#### Pattern 1: 관형형 + 명사 (Attributive + Noun) — 가장 자연스러운 패턴
한국어는 수식어가 앞, 피수식어가 뒤. 형용사의 관형형(~ㄴ/~한/~는/~운)을 명사 앞에 붙인다.
| Structure | GOOD | BAD (역순/비문) |
|---|---|---|
| 형용사 관형형 + 명사 | 고요한 숲, 깊은 쉼, 온전한 쉼 | ~~숲고요~~, ~~쉼깊은~~ |
| 형용사 관형형 + 명사 | 따뜻한 독채, 느린 하루 | ~~독채따뜻~~, ~~하루느린~~ |
| 동사 관형형 + 명사 | 쉬어가는 숲, 머무는 시간 | ~~숲쉬어가는~~ |
#### Pattern 2: 기존 대중화 합성어 ONLY (Established Trending Compound)
이미 SNS·미디어에서 대중화된 합성어만 허용. 임의 신조어 생성 금지.
| GOOD (대중화 확인됨) | Origin | BAD (임의 생성) |
|---|---|---|
| 숲멍 | 숲+멍때리기 (불멍, 물멍 시리즈) | ~~숲고요~~, ~~숲힐~~ |
| 댕캉스 | 댕댕이+바캉스 (여행업계 통용) | ~~댕쉼~~, ~~댕여행~~ |
| 꿀잠 / 꿀쉼 | 꿀+잠/쉼 (일상어 정착) | ~~꿀독채~~, ~~꿀숲~~ |
| 집콕 / 숲콕 | 집+콕 → 숲+콕 (변형 허용) | ~~계곡콕~~ |
| 주말러 | 주말+~러 (~러 접미사 정착) | ~~평일러~~ |
> **판별 기준**: "이 단어를 네이버/인스타에서 검색하면 결과가 나오는가?" YES → 허용, NO → 금지
#### Pattern 3: 명사 + 명사 (Natural Compound Noun)
한국어 복합명사 규칙을 따르는 결합만 허용. 앞 명사가 뒷 명사를 수식하는 관계여야 한다.
| Structure | GOOD | BAD (부자연스러운 결합) |
|---|---|---|
| 장소 + 유형 | 숲속독채, 계곡펜션 | ~~햇살독채~~ (햇살은 장소가 아님) |
| 대상 + 활동 | 반려견산책, 가족피크닉 | ~~견주피크닉~~ (견주가 피크닉하는 건 어색) |
| 시간 + 활동 | 주말탈출, 새벽산책 | ~~자연독채~~ (자연은 시간/방식이 아님) |
#### Pattern 4: 해시태그형 (#키워드)
accent(target_tag) 씬에서만 사용. 기존 검색 키워드를 # 붙여서 사용.
| GOOD | BAD |
|---|---|
| #프라이빗독채, #홍천여행 | #숲고요, #감성쩌는 (검색량 없음) |
#### Pattern 5: 감각/상태 명사 (단독 사용 가능한 것만)
그 자체로 의미가 완결되는 감각·상태 명사만 단독 사용 허용.
| GOOD (단독 의미 완결) | BAD (단독으로 의미 불완전) |
|---|---|
| 고요, 여유, 쉼, 온기 | ~~감성~~, ~~자연~~, ~~힐링~~ (너무 모호) |
| 숲멍, 꿀쉼 | ~~좋은쉼~~, ~~편안함~~ (형용사 포함 시 Pattern 1 사용) |
---
### KEYWORD VALIDATION CHECKLIST (생성 후 자가 검증)
Every keyword MUST pass ALL of these:
- [ ] 한국어 어순이 자연스러운가? (수식어→피수식어 순서)
- [ ] 소리 내어 읽었을 때 어색하지 않은가?
- [ ] 네이버/인스타에서 검색하면 실제 결과가 나올 법한 표현인가?
- [ ] 허용된 5개 Pattern 중 하나에 해당하는가?
- [ ] 이전 씬 keyword와 동일한 Pattern을 연속 사용하지 않았는가?
- [ ] 금지 표현 사전에 해당하지 않는가?
---
### EXPRESSION DICTIONARY
**SCAN BEFORE WRITING.** If JSON contains these → MUST replace:
| Forbidden | → Use Instead |
|---|---|
| 눈치 없는/없이 | 눈치 안 보는 · 프라이빗한 · 온전한 · 마음 편히 |
| 감성 쩌는/쩌이 | 감성 가득한 · 감성이 머무는 |
| 가성비 | 합리적인 · 가치 있는 |
| 힐링되는 | 회복되는 · 쉬어가는 · 숨 쉬는 |
| 인스타감성 | 감성 스팟 · 기록하고 싶은 |
| 혜자 | 풍성한 · 넉넉한 |
**ALWAYS FORBIDDEN**: 저렴한, 싼, 그냥, 보통, 무난한, 평범한, 쩌는, 쩔어, 개(접두사), 존맛, 핵, 인스타, 유튜브, 틱톡
**SYNONYM ROTATION**: Same Korean word max 2 scenes. Rotate:
- 프라이빗 계열: 온전한 · 오롯한 · 나만의 · 독채 · 단독
- 자연 계열: 숲속 · 초록 · 산림 · 계곡
- 쉼 계열: 쉼 · 여유 · 느린 하루 · 머무름 · 숨고르기
- 반려견: 댕댕이(max 1회, intro/accent만) · 반려견 · 우리 강아지
---
### TRANSFORM RULES BY CONTENT_TYPE
**hook_claim** (intro only):
- Format: question OR exclamation OR provocation. Pick ONE.
- FORBIDDEN: brand name, generic greetings
- `"반려견과 눈치 없는 힐링"` → BAD: 그대로 복사 → GOOD: "댕댕이가 먼저 뛰어간 숲"
**space_feature** (core/highlight):
- ONE selling point per scene
- NEVER use korean_category directly
- Viewer must imagine themselves there
- `"홍천 자연 속 조용한 쉼"` → BAD: "입지 환경이 좋은 곳" → GOOD: "계곡 소리만 들리는 독채"
**emotion_cue** (welcome/core/highlight):
- Senses: smell, sound, touch, temperature, light
- Poetic fragments, not full sentences
- `"감성 쩌이 완성되는 공간"` → GOOD: "햇살이 내려앉는 테라스"
**lifestyle_fit** (accent/support):
- Address target directly in their language
- `persona: "서울·경기 주말러"` → GOOD: "이번 주말, 댕댕이랑 어디 가지?"
**local_info** (support):
- Accessibility or charm, NOT administrative address
- GOOD: "서울에서 1시간 반, 홍천 숲속" / BAD: "강원 홍천군 화촌면"
---
### PACING
```
intro(8~12) → welcome(12~18) → core(alternate 8~12 ↔ 12~18) → highlight(8~14) → support(12~18) → accent(variable) → cta(12~16)
```
**RULE: No 3+ consecutive scenes in same char-count range.**
---
Keyword pattern analysis:
- "스테이펫" → brand_name verbatim (허용)
- "고요한 숲" → Pattern 1: 관형형+명사 (형용사 관형형 "고요한" + 명사 "숲")
- "깊은 쉼" → Pattern 1: 관형형+명사 (형용사 관형형 "깊은" + 명사 "쉼")
- "숲멍" → Pattern 2: 기존 대중화 합성어 (불멍·물멍·숲멍 시리즈)
- "댕캉스" → Pattern 2: 기존 대중화 합성어 (댕댕이+바캉스, 여행업계 통용)
- "예약하기" → Pattern 5: 의미 완결 동사 명사형
# 입력
**입력 1: 레이어 이름 리스트**
{pitching_tag_list_string}
**입력 2: 마케팅 인텔리전스 JSON**
{marketing_intelligence}
**입력 3: 비즈니스 정보 **
Business Name: {customer_name}
Region Details: {detail_region_info}

View File

@ -0,0 +1,143 @@
[ROLE]
You are a YouTube SEO/AEO content strategist specialized in local stay, pension, and accommodation brands in Korea.
You create search-optimized, emotionally appealing, and action-driving titles and descriptions based on Brand & Marketing Intelligence.
Your goal is to:
Increase search visibility
Improve click-through rate
Reflect the brands positioning
Trigger emotional interest
Encourage booking or inquiry actions through subtle CTA
[INPUT]
Business Name: {customer_name}
Region Details: {detail_region_info}
Brand & Marketing Intelligence Report: {marketing_intelligence_summary}
Target Keywords: {target_keywords}
Output Language: {language}
[INTERNAL ANALYSIS DO NOT OUTPUT]
Analyze the following from the marketing intelligence:
Core brand concept
Main emotional promise
Primary target persona
Top 23 USP signals
Stay context (date, healing, local trip, etc.)
Search intent behind the target keywords
Main booking trigger
Emotional moment that would make the viewer want to stay
Use these to guide:
Title tone
Opening CTA line
Emotional hook in the first sentences
[TITLE GENERATION RULES]
The title must:
Include the business name or region when natural
Always wrap the business name in quotation marks
Example: “스테이 머뭄”
Include 12 high-intent keywords
Reflect emotional positioning
Suggest a desirable stay moment
Sound like a natural YouTube title, not an advertisement
Length rules:
Hard limit: 100 characters
Target range: 4565 characters
Place primary keyword in the first half
Avoid:
ALL CAPS
Excessive symbols
Price or promotion language
Hard-sell expressions
[DESCRIPTION GENERATION RULES]
Character rules:
Maximum length: 1,000 characters
Critical information must appear within the first 150 characters
Language style rules (mandatory):
Use polite Korean honorific style
Replace “있나요?” with “있으신가요?”
Do not start sentences with “이곳은”
Replace “선택이 됩니다” with “추천 드립니다”
Always wrap the business name in quotation marks
Example: “스테이 머뭄”
Avoid vague location words like “근대거리” alone
Use specific phrasing such as:
“군산 근대역사문화거리 일대”
Structure:
Opening CTA (first line)
Must be a question or gentle suggestion
Must use honorific tone
Example:
“조용히 쉴 수 있는 군산숙소를 찾고 있으신가요?”
Core Stay Introduction (within first 150 characters total)
Mention business name with quotation marks
Mention region
Include main keyword
Briefly describe the stay experience
Brand Experience
Core value and emotional promise
Based on marketing intelligence positioning
Key Highlights (34 short lines)
Derived from USP signals
Natural sentences
Focus on booking-trigger moments
Local Context
Mention nearby experiences
Use specific local references
Example:
“군산 근대역사문화거리 일대 산책이나 로컬 카페 투어”
Soft Closing Line
One gentle, non-salesy closing sentence
Must end with a recommendation tone
Example:
“군산에서 조용한 시간을 보내고 싶다면 ‘스테이 머뭄’을 추천 드립니다.”
[SEO & AEO RULES]
Naturally integrate 35 keywords from {target_keywords}
Avoid keyword stuffing
Use conversational, search-like phrasing
Optimize for:
YouTube search
Google video results
AI answer summaries
Keywords should appear in:
Title (12)
First 150 characters of description
Highlight or context sections
[LANGUAGE RULE]
All output must be written entirely in {language}.
No mixed languages.
[OUTPUT FORMAT STRICT]
title:
description:
No explanations.
No headings.
No extra text.

View File

@ -1,32 +0,0 @@
import copy
import time
import json
from typing import Literal, Any
import httpx
from app.utils.logger import get_logger
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.schemas import *
from app.utils.prompts.prompts import *
class SubtitleContentsGenerator():
def __init__(self):
self.chatgpt_service = ChatgptService()
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str) -> SubtitlePromptOutput:
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list))
pitching_label_string = "\n".join(pitching_label_list)
marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False)
input_data = {
"marketing_intelligence" : marketing_intel_string ,
"pitching_tag_list_string" : pitching_label_string,
"customer_name" : customer_name,
"detail_region_info" : detail_region_info,
}
output_data = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
return output_data

View File

@ -14,8 +14,6 @@ Video API Router
""" """
import json import json
import asyncio
from typing import Literal from typing import Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
@ -25,11 +23,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session from app.database.session import get_session
from app.user.dependencies.auth import get_current_user from app.user.dependencies.auth import get_current_user
from app.user.models import User from app.user.models import User
from app.home.models import Image, Project, MarketingIntel, ImageTag from app.home.models import Image, Project
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp from app.song.models import Song, SongTimestamp
from app.utils.creatomate import CreatomateService from app.utils.creatomate import CreatomateService
from app.utils.subtitles import SubtitleContentsGenerator
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.video.models import Video from app.video.models import Video
from app.video.schemas.video_schema import ( from app.video.schemas.video_schema import (
@ -39,7 +36,6 @@ from app.video.schemas.video_schema import (
VideoRenderData, VideoRenderData,
) )
from app.video.worker.video_task import download_and_upload_video_to_blob from app.video.worker.video_task import download_and_upload_video_to_blob
from app.video.services.video import get_image_tags_by_task_id
from config import creatomate_settings from config import creatomate_settings
@ -148,34 +144,6 @@ async def generate_video(
image_urls: list[str] = [] image_urls: list[str] = []
try: try:
subtitle_done = False
count = 0
async with AsyncSessionLocal() as session:
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
while not subtitle_done:
async with AsyncSessionLocal() as session:
logger.info(f"[generate_video] Checking subtitle- task_id: {task_id}, count : {count}")
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
subtitle_done = bool(marketing_intelligence.subtitle)
if subtitle_done:
logger.info(f"[generate_video] Check subtitle done task_id: {task_id}")
break
await asyncio.sleep(5)
if count > 60 :
raise Exception("subtitle 결과 생성 실패")
count += 1
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음 # 세션을 명시적으로 열고 DB 작업 후 바로 닫음
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# ===== 순차 쿼리 실행: Project, Lyric, Song, Image ===== # ===== 순차 쿼리 실행: Project, Lyric, Song, Image =====
@ -230,12 +198,6 @@ async def generate_video(
) )
project_id = project.id project_id = project.id
store_address = project.detail_region_info store_address = project.detail_region_info
# customer_name = project.store_name
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
# ===== 결과 처리: Lyric ===== # ===== 결과 처리: Lyric =====
lyric = lyric_result.scalar_one_or_none() lyric = lyric_result.scalar_one_or_none()
@ -325,66 +287,50 @@ async def generate_video(
# 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음) # 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음)
# ========================================================================== # ==========================================================================
stage2_start = time.perf_counter() stage2_start = time.perf_counter()
try: try:
logger.info( logger.info(
f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}" f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}"
) )
creatomate_service = CreatomateService( creatomate_service = CreatomateService(
orientation=orientation orientation=orientation,
target_duration=song_duration,
) )
logger.debug( logger.debug(
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})" f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})"
) )
# 6-1. 템플릿 조회 (비동기) # 6-1. 템플릿 조회 (비동기)
template = await creatomate_service.get_one_template_data( template = await creatomate_service.get_one_template_data_async(
creatomate_service.template_id creatomate_service.template_id
) )
logger.debug(f"[generate_video] Template fetched - task_id: {task_id}") logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
# 6-2. elements에서 리소스 매핑 생성 # 6-2. elements에서 리소스 매핑 생성
# modifications = creatomate_service.elements_connect_resource_blackbox( modifications = creatomate_service.elements_connect_resource_blackbox(
# elements=template["source"]["elements"], elements=template["source"]["elements"],
# image_url_list=image_urls, image_url_list=image_urls,
# music_url=music_url, lyric=lyrics,
# address=store_address music_url=music_url,
taged_image_list = await get_image_tags_by_task_id(task_id) address=store_address
min_image_num = creatomate_service.counting_component(
template = template,
target_template_type = "image"
)
duplicate = bool(len(taged_image_list) < min_image_num)
logger.info(f"[generate_video] Duplicate : {duplicate} | length of taged_image {len(taged_image_list)}, min_len {min_image_num},- task_id: {task_id}")
modifications = creatomate_service.template_matching_taged_image(
template = template,
taged_image_list = taged_image_list,
music_url = music_url,
address = store_address,
duplicate = duplicate,
) )
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}") logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
subtitle_modifications = marketing_intelligence.subtitle
modifications.update(subtitle_modifications)
# 6-3. elements 수정 # 6-3. elements 수정
new_elements = creatomate_service.modify_element( new_elements = creatomate_service.modify_element(
template["source"]["elements"], template["source"]["elements"],
modifications, modifications,
) )
template["source"]["elements"] = new_elements template["source"]["elements"] = new_elements
logger.debug(f"[generate_video] Elements modified - task_id: {task_id}") logger.debug(f"[generate_video] Elements modified - task_id: {task_id}")
# 6-4. duration 확장 # 6-4. duration 확장
final_template = creatomate_service.extend_template_duration( final_template = creatomate_service.extend_template_duration(
template, template,
song_duration, creatomate_service.target_duration,
)
logger.debug(
f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}"
) )
logger.debug(f"[generate_video] Duration extended - task_id: {task_id}")
song_timestamp_result = await session.execute( song_timestamp_result = await session.execute(
select(SongTimestamp).where( select(SongTimestamp).where(
@ -393,10 +339,13 @@ async def generate_video(
) )
song_timestamp_list = song_timestamp_result.scalars().all() song_timestamp_list = song_timestamp_result.scalars().all()
logger.debug(f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}") logger.debug(
f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}"
)
for i, ts in enumerate(song_timestamp_list): for i, ts in enumerate(song_timestamp_list):
logger.debug(f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}") logger.debug(
f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}"
)
match lyric_language: match lyric_language:
case "English" : case "English" :
@ -406,32 +355,33 @@ async def generate_video(
lyric_font = "Noto Sans" lyric_font = "Noto Sans"
# LYRIC AUTO 결정부 # LYRIC AUTO 결정부
if (creatomate_settings.LYRIC_SUBTITLE): if (creatomate_settings.DEBUG_AUTO_LYRIC):
if (creatomate_settings.DEBUG_AUTO_LYRIC): auto_text_template = creatomate_service.get_auto_text_template()
auto_text_template = creatomate_service.get_auto_text_template() final_template["source"]["elements"].append(creatomate_service.auto_lyric(auto_text_template))
final_template["source"]["elements"].append(creatomate_service.auto_lyric(auto_text_template)) else :
else : text_template = creatomate_service.get_text_template()
text_template = creatomate_service.get_text_template() for idx, aligned in enumerate(song_timestamp_list):
for idx, aligned in enumerate(song_timestamp_list): caption = creatomate_service.lining_lyric(
caption = creatomate_service.lining_lyric( text_template,
text_template, idx,
idx, aligned.lyric_line,
aligned.lyric_line, aligned.start_time,
aligned.start_time, aligned.end_time,
aligned.end_time, lyric_font
lyric_font )
) final_template["source"]["elements"].append(caption)
final_template["source"]["elements"].append(caption)
# END - LYRIC AUTO 결정부 # END - LYRIC AUTO 결정부
# logger.debug( # logger.debug(
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}" # f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
# ) # )
# 6-5. 커스텀 렌더링 요청 (비동기) # 6-5. 커스텀 렌더링 요청 (비동기)
render_response = await creatomate_service.make_creatomate_custom_call( render_response = await creatomate_service.make_creatomate_custom_call_async(
final_template["source"], final_template["source"],
) )
logger.debug(
logger.debug(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}") f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}"
)
# 렌더 ID 추출 # 렌더 ID 추출
if isinstance(render_response, list) and len(render_response) > 0: if isinstance(render_response, list) and len(render_response) > 0:
@ -452,8 +402,6 @@ async def generate_video(
logger.error( logger.error(
f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}" f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}"
) )
import traceback
logger.error(traceback.format_exc())
# 외부 API 실패 시 Video 상태를 failed로 업데이트 # 외부 API 실패 시 Video 상태를 failed로 업데이트
from app.database.session import AsyncSessionLocal from app.database.session import AsyncSessionLocal
@ -573,13 +521,17 @@ async def get_video_status(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> PollingVideoResponse: ) -> PollingVideoResponse:
"""creatomate_render_id로 영상 생성 작업의 상태를 조회합니다.
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고
Video 테이블의 status를 completed로, result_movie_url을 업데이트합니다.
"""
logger.info( logger.info(
f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}" f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}"
) )
try: try:
creatomate_service = CreatomateService() creatomate_service = CreatomateService()
result = await creatomate_service.get_render_status(creatomate_render_id) result = await creatomate_service.get_render_status_async(creatomate_render_id)
logger.debug( logger.debug(
f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}" f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}"
) )

File diff suppressed because it is too large Load Diff

View File

@ -42,7 +42,6 @@ class ProjectSettings(BaseSettings):
class APIKeySettings(BaseSettings): class APIKeySettings(BaseSettings):
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가 CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
GEMINI_API_KEY: str = Field(default="your-gemeni-api-key") # 기본값 추가
SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키 SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키
SUNO_CALLBACK_URL: str = Field( SUNO_CALLBACK_URL: str = Field(
default="https://example.com/api/suno/callback" default="https://example.com/api/suno/callback"
@ -171,19 +170,22 @@ class CreatomateSettings(BaseSettings):
) )
DEBUG_AUTO_LYRIC: bool = Field( DEBUG_AUTO_LYRIC: bool = Field(
default=False, default=False,
description="Creatomate 자체 자동 가사 생성 기능 사용 여부", description="Creatomate 자동 가사 생성 기능 사용 여부",
)
LYRIC_SUBTITLE: bool = Field(
default=False,
description="영상 가사 표기 여부"
) )
model_config = _base_config model_config = _base_config
class PromptSettings(BaseSettings): class PromptSettings(BaseSettings):
GOOGLE_SERVICE_ACCOUNT_JSON: str = Field(...) PROMPT_FOLDER_ROOT : str = Field(default="./app/utils/prompts/templates")
PROMPT_SPREADSHEET: str = Field(...)
MARKETING_PROMPT_FILE_NAME : str = Field(default="marketing_prompt.txt")
MARKETING_PROMPT_MODEL : str = Field(default="gpt-5.2")
LYRIC_PROMPT_FILE_NAME : str = Field(default="lyric_prompt.txt")
LYRIC_PROMPT_MODEL : str = Field(default="gpt-5-mini")
YOUTUBE_PROMPT_FILE_NAME : str = Field(default="yt_upload_prompt.txt")
YOUTUBE_PROMPT_MODEL : str = Field(default="gpt-5-mini")
model_config = _base_config model_config = _base_config
@ -196,14 +198,6 @@ class RecoverySettings(BaseSettings):
# ============================================================ # ============================================================
# ChatGPT API 설정 # ChatGPT API 설정
# ============================================================ # ============================================================
LLM_TIMEOUT: float = Field(
default=600.0,
description="LLM Default API 타임아웃 (초)",
)
LLM_MAX_RETRIES: int = Field(
default=1,
description="LLM API 응답 실패 시 최대 재시도 횟수",
)
CHATGPT_TIMEOUT: float = Field( CHATGPT_TIMEOUT: float = Field(
default=600.0, default=600.0,
description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)", description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)",
@ -212,6 +206,7 @@ class RecoverySettings(BaseSettings):
default=1, default=1,
description="ChatGPT API 응답 실패 시 최대 재시도 횟수", description="ChatGPT API 응답 실패 시 최대 재시도 횟수",
) )
# ============================================================ # ============================================================
# Suno API 설정 # Suno API 설정
# ============================================================ # ============================================================
@ -263,6 +258,38 @@ class KakaoSettings(BaseSettings):
model_config = _base_config model_config = _base_config
class FacebookSettings(BaseSettings):
"""Facebook OAuth 설정
Facebook Graph API를 통한 OAuth 2.0 인증 설정입니다.
Meta for Developers (https://developers.facebook.com/)에서 앱을 생성하고
App ID/Secret을 발급받아야 합니다.
"""
FACEBOOK_APP_ID: str = Field(
default="",
description="Facebook App ID (Meta 개발자 콘솔에서 발급)",
)
FACEBOOK_APP_SECRET: str = Field(
default="",
description="Facebook App Secret",
)
FACEBOOK_REDIRECT_URI: str = Field(
default="http://localhost:8000/sns/facebook/callback",
description="Facebook OAuth 콜백 URI",
)
FACEBOOK_GRAPH_API_VERSION: str = Field(
default="v21.0",
description="Facebook Graph API 버전",
)
FACEBOOK_OAUTH_SCOPE: str = Field(
default="public_profile,email,pages_show_list,pages_read_engagement,pages_manage_posts",
description="Facebook OAuth 요청 권한 범위 (쉼표 구분)",
)
model_config = _base_config
class JWTSettings(BaseSettings): class JWTSettings(BaseSettings):
"""JWT 토큰 설정""" """JWT 토큰 설정"""
@ -524,16 +551,6 @@ class SocialOAuthSettings(BaseSettings):
# description="Instagram OAuth 콜백 URI", # description="Instagram OAuth 콜백 URI",
# ) # )
# ============================================================
# Facebook (Meta OAuth) - 추후 구현
# ============================================================
# FACEBOOK_APP_ID: str = Field(default="", description="Facebook App ID")
# FACEBOOK_APP_SECRET: str = Field(default="", description="Facebook App Secret")
# FACEBOOK_REDIRECT_URI: str = Field(
# default="http://localhost:8000/social/facebook/callback",
# description="Facebook OAuth 콜백 URI",
# )
# ============================================================ # ============================================================
# TikTok - 추후 구현 # TikTok - 추후 구현
# ============================================================ # ============================================================
@ -626,6 +643,7 @@ creatomate_settings = CreatomateSettings()
prompt_settings = PromptSettings() prompt_settings = PromptSettings()
log_settings = LogSettings() log_settings = LogSettings()
kakao_settings = KakaoSettings() kakao_settings = KakaoSettings()
facebook_settings = FacebookSettings()
jwt_settings = JWTSettings() jwt_settings = JWTSettings()
recovery_settings = RecoverySettings() recovery_settings = RecoverySettings()
social_oauth_settings = SocialOAuthSettings() social_oauth_settings = SocialOAuthSettings()

207
front_plan.md Normal file
View File

@ -0,0 +1,207 @@
# Facebook 로그인 연동 - 프론트엔드 개발계획서
## 1. 개요
CastAD 서비스에 Facebook 계정 연동 기능을 구현한다. 백엔드에 OAuth 2.0 Authorization Code Flow가 이미 구현되어 있으며, 프론트엔드는 이 플로우의 시작(연동 요청)과 끝(결과 수신)을 담당한다.
기술 스택: React 기반 SPA를 전제로 한다.
## 2. OAuth 2.0 전체 플로우
Facebook OAuth 연동은 총 8단계로 이루어진다. 프론트엔드가 직접 관여하는 단계는 1, 2, 8번이며, 3~7번은 Facebook과 백엔드 사이에서 자동으로 처리된다.
**1단계 (프론트 → 백엔드)**: 프론트엔드가 `GET /sns/facebook/connect`를 호출한다. Authorization 헤더에 사용자의 Bearer 토큰을 포함해야 한다. 백엔드는 `auth_url`(Facebook 인증 페이지 URL)과 `state`(CSRF 방지 토큰)를 JSON으로 응답한다.
**2단계 (프론트 → Facebook)**: 프론트엔드가 응답받은 `auth_url`을 팝업 윈도우로 연다. 사용자는 이 팝업에서 Facebook 로그인과 권한 승인을 수행한다.
**3단계 (Facebook → 백엔드)**: 사용자가 승인을 완료하면 Facebook이 백엔드의 `redirect_uri`(`/sns/facebook/callback`)로 리다이렉트한다. 이때 query parameter로 `code`(인가 코드)와 `state`를 전달한다. 프론트엔드는 이 단계에 관여하지 않는다.
**4~6단계 (백엔드 ↔ Facebook)**: 백엔드가 `code`를 Facebook API에 전달하여 access token을 획득하고, 장기 토큰으로 교환한 뒤, 사용자 정보를 조회하여 DB에 저장한다. 프론트엔드는 이 단계에 관여하지 않는다.
**7단계 (백엔드 → 프론트)**: 백엔드가 처리를 완료한 뒤 302 리다이렉트로 프론트엔드 URL로 보낸다. 성공 시 `/social/connect/success`로, 실패 시 `/social/connect/error`로 리다이렉트하며 결과 정보를 query parameter에 포함한다.
**8단계 (프론트)**: 리다이렉트된 페이지(팝업 내부)에서 query parameter를 파싱하고, `window.opener.postMessage()`로 부모 창에 결과를 전달한 뒤 팝업을 닫는다. 부모 창은 메시지를 수신하여 UI를 업데이트한다.
## 3. 백엔드 API 명세
### 3-1. Facebook 연동 시작
- 엔드포인트: `GET /sns/facebook/connect`
- 인증: 필수. `Authorization: Bearer <access_token>` 헤더를 포함해야 한다.
- 성공 응답 (200): `auth_url` 필드에 Facebook 인증 페이지 URL, `state` 필드에 CSRF 방지 토큰이 포함된 JSON 객체를 반환한다.
```json
{
"auth_url": "https://www.facebook.com/v21.0/dialog/oauth?client_id=...&redirect_uri=...&state=...&scope=...&response_type=code",
"state": "5PcHtcvJdQskHw5aG-MIOmv5CCX3L9v3eqQOqyZlKsc"
}
```
- 에러 응답: 401 (인증 실패, 토큰 없음 또는 만료)
### 3-2. Facebook OAuth 콜백
- 엔드포인트: `GET /sns/facebook/callback`
- 인증: 불필요. 이 엔드포인트는 Facebook이 직접 호출하며, 프론트엔드가 직접 호출하지 않는다.
- query parameter: `code` (Facebook 인가 코드), `state` (CSRF 토큰), `error` (선택, OAuth 에러 코드), `error_description` (선택, 에러 설명)
- 응답: JSON이 아닌 302 리다이렉트로 응답한다. 프론트엔드의 성공 또는 에러 페이지로 리다이렉트한다.
성공 시 리다이렉트 URL: `{OAUTH_FRONTEND_URL}/social/connect/success?platform=facebook&account_id={id}&channel_name={name}`
실패 또는 취소 시 리다이렉트 URL: `{OAUTH_FRONTEND_URL}/social/connect/error?platform=facebook&error={message}&cancelled=true|false`
`OAUTH_FRONTEND_URL`은 백엔드 환경변수로 설정되며, 개발 환경 기본값은 `http://localhost:3000`이다. 프론트엔드 배포 도메인과 일치해야 하므로 백엔드 팀과 사전 확인이 필요하다.
### 3-3. Facebook 연동 해제
- 엔드포인트: `DELETE /sns/facebook/disconnect`
- 인증: 필수. `Authorization: Bearer <access_token>` 헤더를 포함해야 한다.
- 성공 응답 (200): `{"success": true, "message": "Facebook 계정 연동이 해제되었습니다."}`
- 에러 응답: 401 (인증 실패), 404 (연동된 Facebook 계정 없음, 에러 코드 `FACEBOOK_ACCOUNT_NOT_FOUND`)
## 4. 프론트엔드 구현 항목
### 4-1. 라우트 등록
React Router에 다음 2개의 라우트를 추가해야 한다. 이 페이지들은 백엔드 콜백의 302 리다이렉트 대상이므로 반드시 존재해야 한다.
- `/social/connect/success`: OAuth 연동 성공 처리 페이지
- `/social/connect/error`: OAuth 연동 실패/취소 처리 페이지
이 두 페이지는 팝업 윈도우 안에서 렌더링되며, 독립적으로 접근 가능해야 한다(SPA 라우팅이 아닌 직접 URL 접근으로도 동작해야 한다).
### 4-2. Facebook 연동 버튼 컴포넌트
소셜 계정 관리 페이지 또는 설정 페이지에 배치한다.
동작 흐름:
1. 사용자가 "Facebook 연동하기" 버튼을 클릭한다.
2. 버튼을 로딩(비활성) 상태로 전환한다.
3. `GET /sns/facebook/connect`를 호출하여 `auth_url``state`를 받는다.
4. `window.open(auth_url, 'facebook-oauth', 'width=600,height=700,scrollbars=yes')`로 팝업을 연다.
5. `window.open()`의 반환값이 `null`이면 브라우저가 팝업을 차단한 것이다. 이 경우 `window.location.href = auth_url`로 현재 창에서 리다이렉트하는 폴백을 적용한다.
6. 팝업에서 `postMessage`를 수신할 `message` 이벤트 리스너를 등록한다.
팝업 방식을 권장하는 이유: 현재 페이지의 상태(폼 입력값, 스크롤 위치 등)를 유지하면서 OAuth 인증을 진행할 수 있다.
### 4-3. OAuth 성공 페이지 (`/social/connect/success`)
이 페이지는 팝업 윈도우 안에서 렌더링된다.
동작 흐름:
1. `URLSearchParams`로 query parameter를 파싱한다. 사용 가능한 파라미터는 `platform`("facebook"), `account_id`(연동된 계정 ID, 숫자), `channel_name`(Facebook 사용자 이름)이다.
2. `window.opener`가 존재하는지 확인한다(팝업으로 열렸는지 판별).
3. 팝업인 경우: `window.opener.postMessage()`로 부모 창에 성공 결과를 전달하고, `window.close()`로 팝업을 닫는다. postMessage의 `targetOrigin`은 보안을 위해 `window.location.origin`으로 지정한다.
4. 팝업이 아닌 경우(폴백 리다이렉트): 소셜 계정 관리 페이지로 `navigate()`한다.
postMessage 데이터 구조:
```json
{
"type": "FACEBOOK_OAUTH_RESULT",
"success": true,
"platform": "facebook",
"accountId": "연동된 계정 ID",
"channelName": "Facebook 사용자 이름"
}
```
### 4-4. OAuth 에러 페이지 (`/social/connect/error`)
이 페이지도 팝업 윈도우 안에서 렌더링된다.
동작 흐름:
1. `URLSearchParams`로 query parameter를 파싱한다. 사용 가능한 파라미터는 `platform`("facebook"), `error`(에러 메시지 문자열), `cancelled`("true" 또는 "false")이다.
2. `cancelled`가 "true"이면 사용자가 Facebook에서 직접 취소한 것이다. "false"이면 시스템 에러이다.
3. 팝업인 경우: `window.opener.postMessage()`로 부모 창에 에러 결과를 전달하고, `window.close()`로 팝업을 닫는다.
4. 팝업이 아닌 경우: 에러 메시지를 화면에 표시하고 "다시 시도" 버튼과 소셜 관리 페이지로 돌아가는 링크를 제공한다.
postMessage 데이터 구조:
```json
{
"type": "FACEBOOK_OAUTH_RESULT",
"success": false,
"platform": "facebook",
"error": "에러 메시지",
"cancelled": true
}
```
### 4-5. 부모 창 메시지 수신 처리
연동 버튼이 있는 컴포넌트에서 `window.addEventListener('message', handler)`로 메시지를 수신한다.
처리 로직:
1. `event.origin`이 현재 페이지의 origin과 일치하는지 검증한다(보안).
2. `event.data.type``'FACEBOOK_OAUTH_RESULT'`인지 확인한다.
3. `event.data.success``true`이면 연동 성공 UI를 표시한다(토스트 메시지, 연동된 계정 정보 갱신 등).
4. `event.data.success``false`이면 `event.data.cancelled` 여부에 따라 취소 메시지 또는 에러 메시지를 표시한다.
5. 컴포넌트 언마운트 시 이벤트 리스너를 반드시 해제한다.
### 4-6. 연동 해제 기능
동작 흐름:
1. 연동된 계정 정보 옆에 "연동 해제" 버튼을 배치한다.
2. 클릭 시 확인 다이얼로그를 표시한다("Facebook 연동을 해제하시겠습니까?").
3. 확인 시 `DELETE /sns/facebook/disconnect`를 호출한다. Authorization 헤더를 포함해야 한다.
4. 성공 응답(200) 수신 시 UI를 미연동 상태로 업데이트한다.
5. 404 응답 시 이미 연동 해제된 상태이므로 UI를 미연동 상태로 업데이트한다.
### 4-7. 연동 상태 표시
연동 전 상태: Facebook 아이콘과 "연동하기" 버튼을 표시한다.
연동 후 상태: Facebook 아이콘, 연동된 사용자 이름(channel_name), "연동 해제" 버튼을 표시한다.
참고: 현재 백엔드에 사용자의 Facebook 연동 상태를 조회하는 전용 API가 없다. 페이지 진입 시 연동 여부를 표시하려면, 기존 사용자 프로필 API에 소셜 연동 정보가 포함되어 있는지 확인하거나, 백엔드에 상태 조회 API 추가를 요청해야 한다. 연동 직후에는 postMessage로 받은 `accountId``channelName`을 컴포넌트 상태에 저장하여 표시할 수 있다.
## 5. 에러 처리
프론트엔드에서 처리해야 하는 에러 시나리오와 대응 방법은 다음과 같다.
**사용자 취소 (`cancelled=true`)**: 사용자가 Facebook 인증 화면에서 "취소"를 눌렀다. "Facebook 연동이 취소되었습니다." 메시지를 표시한다. 재시도 가능하므로 연동 버튼을 다시 활성화한다.
**state 만료 (`FACEBOOK_STATE_EXPIRED`)**: 사용자가 Facebook 로그인을 너무 오래 지체하여 백엔드의 state 토큰이 만료되었다(기본 TTL: 300초, 약 5분). "인증 세션이 만료되었습니다. 다시 시도해주세요." 메시지를 표시한다.
**토큰 교환 실패 (`FACEBOOK_AUTH_FAILED`)**: Facebook에서 받은 인가 코드로 access token 교환이 실패했다. "Facebook 인증에 실패했습니다. 다시 시도해주세요." 메시지를 표시한다.
**Facebook API 오류 (`FACEBOOK_API_ERROR`)**: Facebook Graph API 호출 중 서버 오류가 발생했다. "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요." 메시지를 표시한다.
**연동 해제 시 계정 없음 (`FACEBOOK_ACCOUNT_NOT_FOUND`)**: 이미 연동 해제된 상태에서 다시 해제를 시도했다. UI를 미연동 상태로 갱신한다.
**인증 실패 (401)**: 서비스 로그인 토큰이 만료되었다. 로그인 페이지로 이동시킨다.
## 6. 폴백 처리 (팝업 차단 시)
브라우저가 팝업을 차단하는 경우 `window.open()``null`을 반환한다. 이때는 현재 창에서 `window.location.href = auth_url`로 직접 이동시키는 폴백을 적용한다.
폴백 모드에서는 OAuth 완료 후 `/social/connect/success` 또는 `/social/connect/error` 페이지로 돌아왔을 때 `window.opener``null`이다. 이 경우 postMessage 대신 다음과 같이 처리한다:
1. query parameter에서 결과를 파싱한다.
2. 결과를 `sessionStorage`에 저장한다(키 예시: `facebook_oauth_result`).
3. 소셜 계정 관리 페이지로 `navigate()`한다.
4. 소셜 계정 관리 페이지 마운트 시 `sessionStorage`에서 결과를 확인하고, 있으면 UI에 반영한 뒤 `sessionStorage`에서 삭제한다.
이 폴백이 필요한 이유: 현재 창 리다이렉트 방식에서는 OAuth 전에 있던 페이지 상태가 사라지므로, 결과를 임시 저장하여 원래 페이지로 복귀 후 처리해야 한다.
## 7. 환경 설정 확인 사항
프론트엔드 개발 시작 전에 백엔드 팀과 다음 값들을 확인해야 한다.
1. `OAUTH_FRONTEND_URL`: 백엔드가 콜백 처리 후 프론트엔드로 302 리다이렉트할 때 사용하는 URL이다. 개발 환경에서는 `http://localhost:3000`, 운영 환경에서는 실제 프론트엔드 도메인이 설정되어야 한다. 이 값이 잘못되면 OAuth 완료 후 엉뚱한 URL로 리다이렉트된다.
2. `OAUTH_SUCCESS_PATH`: 성공 시 리다이렉트 경로. 기본값은 `/social/connect/success`이다.
3. `OAUTH_ERROR_PATH`: 실패 시 리다이렉트 경로. 기본값은 `/social/connect/error`이다.
4. `FACEBOOK_REDIRECT_URI`: Facebook이 인증 완료 후 호출하는 백엔드 콜백 URL이다. 프론트엔드가 변경할 수 없으며, Facebook 개발자 콘솔에 등록된 URI와 정확히 일치해야 한다. 현재 설정값은 `http://dev.castad.net/sns/facebook/callback`이다.
## 8. 구현 순서
다음 순서로 구현을 권장한다.
1. React Router에 `/social/connect/success``/social/connect/error` 라우트를 등록한다.
2. 성공 페이지를 구현한다. query parameter 파싱과 postMessage 전송 로직을 작성한다.
3. 에러 페이지를 구현한다. 취소와 에러를 분기 처리하고 postMessage 전송 로직을 작성한다.
4. Facebook 연동 버튼 컴포넌트를 구현한다. API 호출, 팝업 열기, postMessage 수신을 작성한다.
5. 팝업 차단 시 폴백 로직을 구현한다(sessionStorage 기반).
6. 연동 해제 기능을 구현한다.
7. 연동 상태 표시 UI를 구현한다(상태 조회 API 확인 후).

22
main.py
View File

@ -19,6 +19,7 @@ from app.user.api.routers.v1.social_account import router as social_account_rout
from app.lyric.api.routers.v1.lyric import router as lyric_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.song.api.routers.v1.song import router as song_router
from app.sns.api.routers.v1.sns import router as sns_router from app.sns.api.routers.v1.sns import router as sns_router
from app.sns.api.routers.v1.oauth import router as sns_oauth_router
from app.video.api.routers.v1.video import router as video_router from app.video.api.routers.v1.video import router as video_router
from app.social.api.routers.v1.oauth import router as social_oauth_router from app.social.api.routers.v1.oauth import router as social_oauth_router
from app.social.api.routers.v1.upload import router as social_upload_router from app.social.api.routers.v1.upload import router as social_upload_router
@ -267,6 +268,25 @@ tags_metadata = [
1. 사용자의 Instagram 계정이 연동되어 있어야 합니다 (Social Account API 참조) 1. 사용자의 Instagram 계정이 연동되어 있어야 합니다 (Social Account API 참조)
2. task_id에 해당하는 비디오가 생성 완료 상태(result_movie_url 존재)여야 합니다 2. task_id에 해당하는 비디오가 생성 완료 상태(result_movie_url 존재)여야 합니다
3. 업로드 성공 Instagram media_id와 permalink 반환 3. 업로드 성공 Instagram media_id와 permalink 반환
""",
},
{
"name": "SNS OAuth",
"description": """SNS OAuth API - Facebook 계정 연동
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수 (콜백 제외)
## Facebook OAuth 연동 흐름
1. `GET /sns/facebook/connect` - Facebook OAuth 인증 URL 획득
2. 사용자를 auth_url로 리다이렉트 Facebook 로그인 권한 승인
3. Facebook에서 `/sns/facebook/callback`으로 인가 코드 전달
4. 서버에서 토큰 교환, 장기 토큰 발급, 사용자 정보 조회
5. 연동 완료 프론트엔드로 리다이렉트
## 계정 관리
- `DELETE /sns/facebook/disconnect` - Facebook 계정 연동 해제
""", """,
}, },
] ]
@ -335,6 +355,7 @@ def custom_openapi():
"/autocomplete", "/autocomplete",
"/search", # 숙박 검색 자동완성 "/search", # 숙박 검색 자동완성
"/social/oauth/youtube/callback", # OAuth 콜백 (플랫폼에서 직접 호출) "/social/oauth/youtube/callback", # OAuth 콜백 (플랫폼에서 직접 호출)
"/sns/facebook/callback", # Facebook OAuth 콜백
] ]
# 보안이 필요한 엔드포인트에 security 적용 # 보안이 필요한 엔드포인트에 security 적용
@ -395,6 +416,7 @@ app.include_router(social_seo_router, prefix="/social") # Social Upload 라우
app.include_router(social_internal_router) # 내부 스케줄러 전용 라우터 app.include_router(social_internal_router) # 내부 스케줄러 전용 라우터
app.include_router(sns_router) # SNS API 라우터 추가 app.include_router(sns_router) # SNS API 라우터 추가
app.include_router(dashboard_router) # Dashboard API 라우터 추가 app.include_router(dashboard_router) # Dashboard API 라우터 추가
app.include_router(sns_oauth_router) # SNS OAuth 라우터 (Facebook)
# DEBUG 모드에서만 테스트 라우터 등록 # DEBUG 모드에서만 테스트 라우터 등록
if prj_settings.DEBUG: if prj_settings.DEBUG:

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "o2o-castad-backend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@ -12,7 +12,6 @@ dependencies = [
"beautifulsoup4>=4.14.3", "beautifulsoup4>=4.14.3",
"fastapi-cli>=0.0.16", "fastapi-cli>=0.0.16",
"fastapi[standard]>=0.125.0", "fastapi[standard]>=0.125.0",
"gspread>=6.2.1",
"openai>=2.13.0", "openai>=2.13.0",
"playwright>=1.57.0", "playwright>=1.57.0",
"pydantic-settings>=2.12.0", "pydantic-settings>=2.12.0",

115
uv.lock
View File

@ -178,31 +178,6 @@ wheels = [
{ 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/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" },
] ]
[[package]]
name = "charset-normalizer"
version = "3.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
{ url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
{ url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
{ url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
{ url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
{ url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
{ url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
{ url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
{ url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
{ url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
{ url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.1" version = "8.3.1"
@ -438,32 +413,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
] ]
[[package]]
name = "google-auth"
version = "2.49.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" },
]
[[package]]
name = "google-auth-oauthlib"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
{ name = "requests-oauthlib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/82/62482931dcbe5266a2680d0da17096f2aab983ecb320277d9556700ce00e/google_auth_oauthlib-1.3.1.tar.gz", hash = "sha256:14c22c7b3dd3d06dbe44264144409039465effdd1eef94f7ce3710e486cc4bfa", size = 21663, upload-time = "2026-03-30T22:49:56.408Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" },
]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.3.0" version = "3.3.0"
@ -480,19 +429,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
] ]
[[package]]
name = "gspread"
version = "6.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
{ name = "google-auth-oauthlib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/83/42d1d813822ed016d77aabadc99b09de3b5bd68532fd6bae23fd62347c41/gspread-6.2.1.tar.gz", hash = "sha256:2c7c99f7c32ebea6ec0d36f2d5cbe8a2be5e8f2a48bde87ad1ea203eff32bd03", size = 82590, upload-time = "2025-05-14T15:56:25.254Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/76/563fb20dedd0e12794d9a12cfe0198458cc0501fdc7b034eee2166d035d5/gspread-6.2.1-py3-none-any.whl", hash = "sha256:6d4ec9f1c23ae3c704a9219026dac01f2b328ac70b96f1495055d453c4c184db", size = 59977, upload-time = "2025-05-14T15:56:24.014Z" },
]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@ -718,7 +654,6 @@ dependencies = [
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "fastapi-cli" }, { name = "fastapi-cli" },
{ name = "gspread" },
{ name = "openai" }, { name = "openai" },
{ name = "playwright" }, { name = "playwright" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@ -748,7 +683,6 @@ requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "beautifulsoup4", specifier = ">=4.14.3" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
{ name = "fastapi-cli", specifier = ">=0.0.16" }, { name = "fastapi-cli", specifier = ">=0.0.16" },
{ name = "gspread", specifier = ">=6.2.1" },
{ name = "openai", specifier = ">=2.13.0" }, { name = "openai", specifier = ">=2.13.0" },
{ name = "playwright", specifier = ">=1.57.0" }, { name = "playwright", specifier = ">=1.57.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" },
@ -769,15 +703,6 @@ dev = [
{ name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" },
] ]
[[package]]
name = "oauthlib"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
]
[[package]] [[package]]
name = "openai" name = "openai"
version = "2.15.0" version = "2.15.0"
@ -882,18 +807,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
] ]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" version = "3.0"
@ -1097,34 +1010,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
] ]
[[package]]
name = "requests"
version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]
name = "requests-oauthlib"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "oauthlib" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.2.0" version = "14.2.0"