first commit
parent
fa8ce3d071
commit
6d86aaa1be
|
|
@ -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 패턴 |
|
||||
|
|
@ -24,10 +24,12 @@ engine = create_async_engine(
|
|||
max_overflow=20, # 추가 연결: 20 (총 최대 40)
|
||||
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
|
||||
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
|
||||
pool_use_lifo=True,
|
||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
|
||||
connect_args={
|
||||
"connect_timeout": 10, # DB 연결 타임아웃
|
||||
"read_timeout": 30,
|
||||
"charset": "utf8mb4",
|
||||
},
|
||||
)
|
||||
|
|
@ -51,10 +53,12 @@ background_engine = create_async_engine(
|
|||
max_overflow=10, # 추가 연결: 10 (총 최대 20)
|
||||
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
|
||||
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
||||
pool_use_lifo=True,
|
||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||
pool_reset_on_return="rollback",
|
||||
connect_args={
|
||||
"connect_timeout": 10,
|
||||
"read_timeout": 30,
|
||||
"charset": "utf8mb4",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 계정 연동이 해제되었습니다."}
|
||||
|
|
@ -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
|
||||
|
|
@ -52,6 +52,7 @@ class SNSUploadTask(Base):
|
|||
Index("idx_sns_upload_task_user_uuid", "user_uuid"),
|
||||
Index("idx_sns_upload_task_task_id", "task_id"),
|
||||
Index("idx_sns_upload_task_social_account_id", "social_account_id"),
|
||||
Index("idx_sns_upload_task_platform", "platform"),
|
||||
Index("idx_sns_upload_task_status", "status"),
|
||||
Index("idx_sns_upload_task_is_scheduled", "is_scheduled"),
|
||||
Index("idx_sns_upload_task_scheduled_at", "scheduled_at"),
|
||||
|
|
@ -116,6 +117,12 @@ class SNSUploadTask(Base):
|
|||
comment="소셜 계정 외래키 (SocialAccount.id 참조)",
|
||||
)
|
||||
|
||||
platform: Mapped[Optional[str]] = mapped_column(
|
||||
String(20),
|
||||
nullable=True,
|
||||
comment="업로드 대상 플랫폼 (instagram, facebook 등)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 업로드 콘텐츠
|
||||
# ==========================================================================
|
||||
|
|
@ -131,6 +138,18 @@ class SNSUploadTask(Base):
|
|||
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)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 발행 상태
|
||||
# ==========================================================================
|
||||
|
|
|
|||
|
|
@ -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 페이지 목록"
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
43
config.py
43
config.py
|
|
@ -258,6 +258,38 @@ class KakaoSettings(BaseSettings):
|
|||
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):
|
||||
"""JWT 토큰 설정"""
|
||||
|
||||
|
|
@ -519,16 +551,6 @@ class SocialOAuthSettings(BaseSettings):
|
|||
# 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 - 추후 구현
|
||||
# ============================================================
|
||||
|
|
@ -610,6 +632,7 @@ creatomate_settings = CreatomateSettings()
|
|||
prompt_settings = PromptSettings()
|
||||
log_settings = LogSettings()
|
||||
kakao_settings = KakaoSettings()
|
||||
facebook_settings = FacebookSettings()
|
||||
jwt_settings = JWTSettings()
|
||||
recovery_settings = RecoverySettings()
|
||||
social_oauth_settings = SocialOAuthSettings()
|
||||
|
|
|
|||
22
main.py
22
main.py
|
|
@ -18,6 +18,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.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.oauth import router as sns_oauth_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.upload import router as social_upload_router
|
||||
|
|
@ -237,6 +238,25 @@ tags_metadata = [
|
|||
1. 사용자의 Instagram 계정이 연동되어 있어야 합니다 (Social Account API 참조)
|
||||
2. task_id에 해당하는 비디오가 생성 완료 상태(result_movie_url 존재)여야 합니다
|
||||
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 계정 연동 해제
|
||||
""",
|
||||
},
|
||||
]
|
||||
|
|
@ -305,6 +325,7 @@ def custom_openapi():
|
|||
"/autocomplete",
|
||||
"/search", # 숙박 검색 자동완성
|
||||
"/social/oauth/youtube/callback", # OAuth 콜백 (플랫폼에서 직접 호출)
|
||||
"/sns/facebook/callback", # Facebook OAuth 콜백
|
||||
]
|
||||
|
||||
# 보안이 필요한 엔드포인트에 security 적용
|
||||
|
|
@ -363,6 +384,7 @@ app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우
|
|||
app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가
|
||||
app.include_router(social_seo_router, prefix="/social") # Social Upload 라우터 추가
|
||||
app.include_router(sns_router) # SNS API 라우터 추가
|
||||
app.include_router(sns_oauth_router) # SNS OAuth 라우터 (Facebook)
|
||||
|
||||
# DEBUG 모드에서만 테스트 라우터 등록
|
||||
if prj_settings.DEBUG:
|
||||
|
|
|
|||
Loading…
Reference in New Issue