Compare commits
No commits in common. "e1386b891e89f9b49abe408588d7d89c5cce4d4e" and "f97ecb29e92adfa5e831fa3579354fba549a3c2e" have entirely different histories.
e1386b891e
...
f97ecb29e9
|
|
@ -37,7 +37,7 @@ router = APIRouter(prefix="/archive", tags=["Archive"])
|
|||
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
||||
|
||||
## 반환 정보
|
||||
- **items**: 영상 목록 (video_id, store_name, region, task_id, result_movie_url, created_at)
|
||||
- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at)
|
||||
- **total**: 전체 데이터 수
|
||||
- **page**: 현재 페이지
|
||||
- **page_size**: 페이지당 데이터 수
|
||||
|
|
@ -53,7 +53,7 @@ GET /archive/videos/?page=1&page_size=10
|
|||
## 참고
|
||||
- **본인이 소유한 프로젝트의 영상만 반환됩니다.**
|
||||
- status가 'completed'인 영상만 반환됩니다.
|
||||
- 재생성된 영상 포함 모든 영상이 반환됩니다.
|
||||
- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다.
|
||||
- created_at 기준 내림차순 정렬됩니다.
|
||||
""",
|
||||
response_model=PaginatedResponse[VideoListItem],
|
||||
|
|
@ -149,21 +149,35 @@ async def get_videos(
|
|||
Project.is_deleted == False,
|
||||
]
|
||||
|
||||
# 쿼리 1: 전체 개수 조회 (모든 영상)
|
||||
# 쿼리 1: 전체 개수 조회 (task_id 기준 고유 개수)
|
||||
count_query = (
|
||||
select(func.count(Video.id))
|
||||
select(func.count(func.distinct(Video.task_id)))
|
||||
.join(Project, Video.project_id == Project.id)
|
||||
.where(*base_conditions)
|
||||
)
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
logger.debug(f"[get_videos] DEBUG - 전체 영상 개수 (total): {total}")
|
||||
logger.debug(f"[get_videos] DEBUG - task_id 기준 고유 개수 (total): {total}")
|
||||
|
||||
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회 (모든 영상)
|
||||
# 서브쿼리: task_id별 최신 Video의 id 조회
|
||||
subquery = (
|
||||
select(func.max(Video.id).label("max_id"))
|
||||
.join(Project, Video.project_id == Project.id)
|
||||
.where(*base_conditions)
|
||||
.group_by(Video.task_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# DEBUG: 서브쿼리 결과 확인
|
||||
subquery_debug_result = await session.execute(select(subquery.c.max_id))
|
||||
subquery_ids = [row[0] for row in subquery_debug_result.all()]
|
||||
logger.debug(f"[get_videos] DEBUG - 서브쿼리 결과 (max_id 목록): {subquery_ids}")
|
||||
|
||||
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회
|
||||
query = (
|
||||
select(Video, Project)
|
||||
.join(Project, Video.project_id == Project.id)
|
||||
.where(*base_conditions)
|
||||
.where(Video.id.in_(select(subquery.c.max_id)))
|
||||
.order_by(Video.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(pagination.page_size)
|
||||
|
|
@ -176,7 +190,6 @@ async def get_videos(
|
|||
items = []
|
||||
for video, project in rows:
|
||||
item = VideoListItem(
|
||||
video_id=video.id,
|
||||
store_name=project.store_name,
|
||||
region=project.region,
|
||||
task_id=video.task_id,
|
||||
|
|
@ -206,94 +219,9 @@ async def get_videos(
|
|||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/videos/{video_id}",
|
||||
summary="개별 영상 소프트 삭제",
|
||||
description="""
|
||||
## 개요
|
||||
video_id에 해당하는 영상만 소프트 삭제합니다.
|
||||
(is_deleted=True로 설정, 실제 데이터는 DB에 유지)
|
||||
|
||||
## 경로 파라미터
|
||||
- **video_id**: 삭제할 영상의 ID (Video.id)
|
||||
|
||||
## 참고
|
||||
- 본인이 소유한 프로젝트의 영상만 삭제할 수 있습니다.
|
||||
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
|
||||
- 프로젝트나 다른 관련 데이터(Song, Lyric 등)는 삭제되지 않습니다.
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "삭제 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
403: {"description": "삭제 권한 없음"},
|
||||
404: {"description": "영상을 찾을 수 없음"},
|
||||
500: {"description": "삭제 실패"},
|
||||
},
|
||||
)
|
||||
async def delete_single_video(
|
||||
video_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""video_id에 해당하는 개별 영상만 소프트 삭제합니다."""
|
||||
logger.info(f"[delete_single_video] START - video_id: {video_id}, user: {current_user.user_uuid}")
|
||||
|
||||
try:
|
||||
# Video 조회 (Project와 함께)
|
||||
result = await session.execute(
|
||||
select(Video, Project)
|
||||
.join(Project, Video.project_id == Project.id)
|
||||
.where(
|
||||
Video.id == video_id,
|
||||
Video.is_deleted == False,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
|
||||
if row is None:
|
||||
logger.warning(f"[delete_single_video] NOT FOUND - video_id: {video_id}")
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="영상을 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
video, project = row
|
||||
|
||||
# 소유권 검증
|
||||
if project.user_uuid != current_user.user_uuid:
|
||||
logger.warning(
|
||||
f"[delete_single_video] FORBIDDEN - video_id: {video_id}, "
|
||||
f"owner: {project.user_uuid}, requester: {current_user.user_uuid}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="삭제 권한이 없습니다.",
|
||||
)
|
||||
|
||||
# 소프트 삭제
|
||||
video.is_deleted = True
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"[delete_single_video] SUCCESS - video_id: {video_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "영상이 삭제되었습니다.",
|
||||
"video_id": video_id,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[delete_single_video] EXCEPTION - video_id: {video_id}, error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"삭제에 실패했습니다: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/videos/delete/{task_id}",
|
||||
summary="프로젝트 전체 소프트 삭제 (task_id 기준)",
|
||||
summary="아카이브 영상 소프트 삭제",
|
||||
description="""
|
||||
## 개요
|
||||
task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트 삭제합니다.
|
||||
|
|
@ -314,7 +242,6 @@ task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트
|
|||
- 본인이 소유한 프로젝트만 삭제할 수 있습니다.
|
||||
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
|
||||
- 백그라운드에서 비동기로 처리됩니다.
|
||||
- **개별 영상만 삭제하려면 DELETE /archive/videos/{video_id}를 사용하세요.**
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "삭제 요청 성공"},
|
||||
|
|
|
|||
|
|
@ -288,20 +288,6 @@ def add_exception_handlers(app: FastAPI):
|
|||
),
|
||||
)
|
||||
|
||||
# SocialException 핸들러 추가
|
||||
from app.social.exceptions import SocialException
|
||||
|
||||
@app.exception_handler(SocialException)
|
||||
def social_exception_handler(request: Request, exc: SocialException) -> Response:
|
||||
logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}")
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"detail": exc.message,
|
||||
"code": exc.code,
|
||||
},
|
||||
)
|
||||
|
||||
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
def internal_server_error_handler(request, exception):
|
||||
return JSONResponse(
|
||||
|
|
|
|||
|
|
@ -292,21 +292,10 @@ async def generate_lyric(
|
|||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 2: Project 조회 또는 생성 ==========
|
||||
# ========== Step 2: Project 테이블에 데이터 저장 ==========
|
||||
step2_start = time.perf_counter()
|
||||
logger.debug(f"[generate_lyric] Step 2: Project 조회 또는 생성...")
|
||||
logger.debug(f"[generate_lyric] Step 2: Project 저장...")
|
||||
|
||||
# 기존 Project가 있는지 확인 (재생성 시 재사용)
|
||||
existing_project_result = await session.execute(
|
||||
select(Project).where(Project.task_id == task_id).limit(1)
|
||||
)
|
||||
project = existing_project_result.scalar_one_or_none()
|
||||
|
||||
if project:
|
||||
# 기존 Project 재사용 (재생성 케이스)
|
||||
logger.info(f"[generate_lyric] 기존 Project 재사용 - project_id: {project.id}, task_id: {task_id}")
|
||||
else:
|
||||
# 새 Project 생성 (최초 생성 케이스)
|
||||
project = Project(
|
||||
store_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
|
|
@ -318,7 +307,6 @@ async def generate_lyric(
|
|||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
logger.info(f"[generate_lyric] 새 Project 생성 - project_id: {project.id}, task_id: {task_id}")
|
||||
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.debug(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
|
||||
|
|
@ -352,7 +340,6 @@ async def generate_lyric(
|
|||
task_id=task_id,
|
||||
prompt=lyric_prompt,
|
||||
lyric_input_data=lyric_input_data,
|
||||
lyric_id=lyric.id,
|
||||
)
|
||||
|
||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ async def _update_lyric_status(
|
|||
task_id: str,
|
||||
status: str,
|
||||
result: str | None = None,
|
||||
lyric_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Lyric 테이블의 상태를 업데이트합니다.
|
||||
|
||||
|
|
@ -31,20 +30,12 @@ async def _update_lyric_status(
|
|||
task_id: 프로젝트 task_id
|
||||
status: 변경할 상태 ("processing", "completed", "failed")
|
||||
result: 가사 결과 또는 에러 메시지
|
||||
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
if lyric_id:
|
||||
# lyric_id로 특정 레코드 조회 (재생성 시에도 정확한 레코드 업데이트)
|
||||
query_result = await session.execute(
|
||||
select(Lyric).where(Lyric.id == lyric_id)
|
||||
)
|
||||
else:
|
||||
# 기존 방식: task_id로 최신 레코드 조회
|
||||
query_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
|
|
@ -58,17 +49,17 @@ async def _update_lyric_status(
|
|||
if result is not None:
|
||||
lyric.lyric_result = result
|
||||
await session.commit()
|
||||
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
|
||||
logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}, lyric_id: {lyric_id}")
|
||||
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, lyric_id: {lyric_id}, error: {e}")
|
||||
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, lyric_id: {lyric_id}, error: {e}")
|
||||
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -76,15 +67,13 @@ async def generate_lyric_background(
|
|||
task_id: str,
|
||||
prompt: Prompt,
|
||||
lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input
|
||||
lyric_id: int | None = None,
|
||||
) -> None:
|
||||
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
prompt: ChatGPT에 전달할 프롬프트
|
||||
lyric_input_data: 프롬프트 입력 데이터
|
||||
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||
language: 가사 언어
|
||||
"""
|
||||
import time
|
||||
|
||||
|
|
@ -127,7 +116,7 @@ async def generate_lyric_background(
|
|||
step3_start = time.perf_counter()
|
||||
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
||||
|
||||
await _update_lyric_status(task_id, "completed", result, lyric_id)
|
||||
await _update_lyric_status(task_id, "completed", result)
|
||||
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
|
||||
|
|
@ -147,14 +136,14 @@ async def generate_lyric_background(
|
|||
f"[generate_lyric_background] ChatGPT ERROR - task_id: {task_id}, "
|
||||
f"status: {e.status}, code: {e.error_code}, message: {e.error_message} ({elapsed:.1f}ms)"
|
||||
)
|
||||
await _update_lyric_status(task_id, "failed", f"ChatGPT Error: {e.error_message}", lyric_id)
|
||||
await _update_lyric_status(task_id, "failed", f"ChatGPT Error: {e.error_message}")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}", lyric_id)
|
||||
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", 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)}")
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
"""
|
||||
Social Media Integration Module
|
||||
|
||||
소셜 미디어 플랫폼 연동 및 영상 업로드 기능을 제공합니다.
|
||||
|
||||
지원 플랫폼:
|
||||
- YouTube (구현됨)
|
||||
- Instagram (추후 구현)
|
||||
- Facebook (추후 구현)
|
||||
- TikTok (추후 구현)
|
||||
"""
|
||||
|
||||
from app.social.constants import SocialPlatform, UploadStatus
|
||||
|
||||
__all__ = ["SocialPlatform", "UploadStatus"]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Social API Module
|
||||
"""
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Social API Routers
|
||||
"""
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
"""
|
||||
Social API Routers v1
|
||||
"""
|
||||
|
||||
from app.social.api.routers.v1.oauth import router as oauth_router
|
||||
from app.social.api.routers.v1.upload import router as upload_router
|
||||
|
||||
__all__ = ["oauth_router", "upload_router"]
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
"""
|
||||
소셜 OAuth API 라우터
|
||||
|
||||
소셜 미디어 계정 연동 관련 엔드포인트를 제공합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import social_oauth_settings
|
||||
from app.database.session import get_session
|
||||
from app.social.constants import SocialPlatform
|
||||
from app.social.schemas import (
|
||||
MessageResponse,
|
||||
SocialAccountListResponse,
|
||||
SocialAccountResponse,
|
||||
SocialConnectResponse,
|
||||
)
|
||||
from app.social.services import social_account_service
|
||||
from app.user.dependencies import get_current_user
|
||||
from app.user.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/oauth", tags=["Social OAuth"])
|
||||
|
||||
|
||||
def _build_redirect_url(is_success: bool, params: dict) -> str:
|
||||
"""OAuth 리다이렉트 URL 생성"""
|
||||
base_url = social_oauth_settings.OAUTH_FRONTEND_URL.rstrip("/")
|
||||
path = (
|
||||
social_oauth_settings.OAUTH_SUCCESS_PATH
|
||||
if is_success
|
||||
else social_oauth_settings.OAUTH_ERROR_PATH
|
||||
)
|
||||
return f"{base_url}{path}?{urlencode(params)}"
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{platform}/connect",
|
||||
response_model=SocialConnectResponse,
|
||||
summary="소셜 계정 연동 시작",
|
||||
description="""
|
||||
소셜 미디어 계정 연동을 시작합니다.
|
||||
|
||||
## 지원 플랫폼
|
||||
- **youtube**: YouTube (Google OAuth)
|
||||
- instagram, facebook, tiktok: 추후 지원 예정
|
||||
|
||||
## 플로우
|
||||
1. 이 엔드포인트를 호출하여 `auth_url`과 `state`를 받음
|
||||
2. 프론트엔드에서 `auth_url`로 사용자를 리다이렉트
|
||||
3. 사용자가 플랫폼에서 권한 승인
|
||||
4. 플랫폼이 `/callback` 엔드포인트로 리다이렉트
|
||||
5. 연동 완료 후 프론트엔드로 리다이렉트
|
||||
""",
|
||||
)
|
||||
async def start_connect(
|
||||
platform: SocialPlatform,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> SocialConnectResponse:
|
||||
"""
|
||||
소셜 계정 연동 시작
|
||||
|
||||
OAuth 인증 URL을 생성하고 state 토큰을 반환합니다.
|
||||
프론트엔드에서 반환된 auth_url로 사용자를 리다이렉트하면 됩니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[OAUTH_API] 소셜 연동 시작 - "
|
||||
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
return await social_account_service.start_connect(
|
||||
user_uuid=current_user.user_uuid,
|
||||
platform=platform,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{platform}/callback",
|
||||
summary="OAuth 콜백",
|
||||
description="""
|
||||
소셜 플랫폼의 OAuth 콜백을 처리합니다.
|
||||
|
||||
이 엔드포인트는 소셜 플랫폼에서 직접 호출되며,
|
||||
사용자를 프론트엔드로 리다이렉트합니다.
|
||||
""",
|
||||
)
|
||||
async def oauth_callback(
|
||||
platform: SocialPlatform,
|
||||
code: str | None = Query(None, description="OAuth 인가 코드"),
|
||||
state: str | None = Query(None, description="CSRF 방지용 state 토큰"),
|
||||
error: str | None = Query(None, description="OAuth 에러 코드 (사용자 취소 등)"),
|
||||
error_description: str | None = Query(None, description="OAuth 에러 설명"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RedirectResponse:
|
||||
"""
|
||||
OAuth 콜백 처리
|
||||
|
||||
소셜 플랫폼에서 리다이렉트된 후 호출됩니다.
|
||||
인가 코드로 토큰을 교환하고 계정을 연동합니다.
|
||||
"""
|
||||
# 사용자가 취소하거나 에러가 발생한 경우
|
||||
if error:
|
||||
logger.info(
|
||||
f"[OAUTH_API] OAuth 취소/에러 - "
|
||||
f"platform: {platform.value}, error: {error}, description: {error_description}"
|
||||
)
|
||||
|
||||
# 에러 메시지 생성
|
||||
if error == "access_denied":
|
||||
error_message = "사용자가 연동을 취소했습니다."
|
||||
else:
|
||||
error_message = error_description or error
|
||||
|
||||
redirect_url = _build_redirect_url(
|
||||
is_success=False,
|
||||
params={
|
||||
"platform": platform.value,
|
||||
"error": error_message,
|
||||
"cancelled": "true" if error == "access_denied" else "false",
|
||||
},
|
||||
)
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
# code나 state가 없는 경우
|
||||
if not code or not state:
|
||||
logger.warning(
|
||||
f"[OAUTH_API] OAuth 콜백 파라미터 누락 - "
|
||||
f"platform: {platform.value}, code: {bool(code)}, state: {bool(state)}"
|
||||
)
|
||||
redirect_url = _build_redirect_url(
|
||||
is_success=False,
|
||||
params={
|
||||
"platform": platform.value,
|
||||
"error": "잘못된 요청입니다. 다시 시도해주세요.",
|
||||
},
|
||||
)
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
logger.info(
|
||||
f"[OAUTH_API] OAuth 콜백 수신 - "
|
||||
f"platform: {platform.value}, code: {code[:20]}..."
|
||||
)
|
||||
|
||||
try:
|
||||
account = await social_account_service.handle_callback(
|
||||
code=code,
|
||||
state=state,
|
||||
session=session,
|
||||
)
|
||||
|
||||
# 성공 시 프론트엔드로 리다이렉트 (계정 정보 포함)
|
||||
redirect_url = _build_redirect_url(
|
||||
is_success=True,
|
||||
params={
|
||||
"platform": platform.value,
|
||||
"account_id": account.id,
|
||||
"channel_name": account.display_name or account.platform_username or "",
|
||||
"profile_image": account.profile_image_url or "",
|
||||
},
|
||||
)
|
||||
logger.info(f"[OAUTH_API] 연동 성공, 리다이렉트 - url: {redirect_url}")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[OAUTH_API] OAuth 콜백 처리 실패 - error: {e}")
|
||||
# 실패 시 에러 페이지로 리다이렉트
|
||||
redirect_url = _build_redirect_url(
|
||||
is_success=False,
|
||||
params={
|
||||
"platform": platform.value,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/accounts",
|
||||
response_model=SocialAccountListResponse,
|
||||
summary="연동된 소셜 계정 목록 조회",
|
||||
description="현재 사용자가 연동한 모든 소셜 계정 목록을 반환합니다.",
|
||||
)
|
||||
async def get_connected_accounts(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialAccountListResponse:
|
||||
"""
|
||||
연동된 소셜 계정 목록 조회
|
||||
|
||||
현재 로그인한 사용자가 연동한 모든 소셜 계정을 조회합니다.
|
||||
"""
|
||||
logger.info(f"[OAUTH_API] 연동 계정 목록 조회 - user_uuid: {current_user.user_uuid}")
|
||||
|
||||
accounts = await social_account_service.get_connected_accounts(
|
||||
user_uuid=current_user.user_uuid,
|
||||
session=session,
|
||||
)
|
||||
|
||||
return SocialAccountListResponse(
|
||||
accounts=accounts,
|
||||
total=len(accounts),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/accounts/{platform}",
|
||||
response_model=SocialAccountResponse,
|
||||
summary="특정 플랫폼 연동 계정 조회",
|
||||
description="특정 플랫폼에 연동된 계정 정보를 반환합니다.",
|
||||
)
|
||||
async def get_account_by_platform(
|
||||
platform: SocialPlatform,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialAccountResponse:
|
||||
"""
|
||||
특정 플랫폼 연동 계정 조회
|
||||
"""
|
||||
logger.info(
|
||||
f"[OAUTH_API] 특정 플랫폼 계정 조회 - "
|
||||
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
account = await social_account_service.get_account_by_platform(
|
||||
user_uuid=current_user.user_uuid,
|
||||
platform=platform,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if account is None:
|
||||
from app.social.exceptions import SocialAccountNotFoundError
|
||||
|
||||
raise SocialAccountNotFoundError(platform=platform.value)
|
||||
|
||||
return social_account_service._to_response(account)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/accounts/{account_id}",
|
||||
response_model=MessageResponse,
|
||||
summary="소셜 계정 연동 해제 (account_id)",
|
||||
description="""
|
||||
소셜 미디어 계정 연동을 해제합니다.
|
||||
|
||||
## 경로 파라미터
|
||||
- **account_id**: 연동 해제할 소셜 계정 ID (SocialAccount.id)
|
||||
|
||||
## 연동 해제 시
|
||||
- 해당 플랫폼으로의 업로드가 불가능해집니다
|
||||
- 기존 업로드 기록은 유지됩니다
|
||||
- 재연동 시 동의 화면이 스킵됩니다
|
||||
""",
|
||||
)
|
||||
async def disconnect_by_account_id(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
소셜 계정 연동 해제 (account_id 기준)
|
||||
|
||||
account_id로 특정 소셜 계정의 연동을 해제합니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[OAUTH_API] 소셜 연동 해제 (by account_id) - "
|
||||
f"user_uuid: {current_user.user_uuid}, account_id: {account_id}"
|
||||
)
|
||||
|
||||
platform = await social_account_service.disconnect_by_account_id(
|
||||
user_uuid=current_user.user_uuid,
|
||||
account_id=account_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
success=True,
|
||||
message=f"{platform} 계정 연동이 해제되었습니다.",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{platform}/disconnect",
|
||||
response_model=MessageResponse,
|
||||
summary="소셜 계정 연동 해제 (platform)",
|
||||
description="""
|
||||
소셜 미디어 계정 연동을 해제합니다.
|
||||
|
||||
**주의**: 이 API는 플랫폼당 1개의 계정만 연동된 경우에 사용합니다.
|
||||
여러 채널이 연동된 경우 `DELETE /accounts/{account_id}`를 사용하세요.
|
||||
|
||||
연동 해제 시:
|
||||
- 해당 플랫폼으로의 업로드가 불가능해집니다
|
||||
- 기존 업로드 기록은 유지됩니다
|
||||
""",
|
||||
deprecated=True,
|
||||
)
|
||||
async def disconnect(
|
||||
platform: SocialPlatform,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
소셜 계정 연동 해제 (platform 기준)
|
||||
|
||||
플랫폼으로 연동된 첫 번째 계정을 해제합니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[OAUTH_API] 소셜 연동 해제 - "
|
||||
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
await social_account_service.disconnect(
|
||||
user_uuid=current_user.user_uuid,
|
||||
platform=platform,
|
||||
session=session,
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
success=True,
|
||||
message=f"{platform.value} 계정 연동이 해제되었습니다.",
|
||||
)
|
||||
|
|
@ -1,413 +0,0 @@
|
|||
"""
|
||||
소셜 업로드 API 라우터
|
||||
|
||||
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.social.constants import SocialPlatform, UploadStatus
|
||||
from app.social.exceptions import SocialAccountNotFoundError, VideoNotFoundError
|
||||
from app.social.models import SocialUpload
|
||||
from app.social.schemas import (
|
||||
MessageResponse,
|
||||
SocialUploadHistoryItem,
|
||||
SocialUploadHistoryResponse,
|
||||
SocialUploadRequest,
|
||||
SocialUploadResponse,
|
||||
SocialUploadStatusResponse,
|
||||
)
|
||||
from app.social.services import social_account_service
|
||||
from app.social.worker.upload_task import process_social_upload
|
||||
from app.user.dependencies import get_current_user
|
||||
from app.user.models import User
|
||||
from app.video.models import Video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["Social Upload"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=SocialUploadResponse,
|
||||
summary="소셜 플랫폼에 영상 업로드 요청",
|
||||
description="""
|
||||
영상을 소셜 미디어 플랫폼에 업로드합니다.
|
||||
|
||||
## 사전 조건
|
||||
- 해당 플랫폼에 계정이 연동되어 있어야 합니다
|
||||
- 영상이 completed 상태여야 합니다 (result_movie_url 필요)
|
||||
|
||||
## 요청 필드
|
||||
- **video_id**: 업로드할 영상 ID
|
||||
- **social_account_id**: 업로드할 소셜 계정 ID (연동 계정 목록 조회 API에서 확인)
|
||||
- **title**: 영상 제목 (최대 100자)
|
||||
- **description**: 영상 설명 (최대 5000자)
|
||||
- **tags**: 태그 목록
|
||||
- **privacy_status**: 공개 상태 (public, unlisted, private)
|
||||
- **scheduled_at**: 예약 게시 시간 (선택사항)
|
||||
|
||||
## 업로드 상태
|
||||
업로드는 백그라운드에서 처리되며, 상태를 폴링하여 확인할 수 있습니다:
|
||||
- `pending`: 업로드 대기 중
|
||||
- `uploading`: 업로드 진행 중
|
||||
- `processing`: 플랫폼에서 처리 중
|
||||
- `completed`: 업로드 완료
|
||||
- `failed`: 업로드 실패
|
||||
""",
|
||||
)
|
||||
async def upload_to_social(
|
||||
body: SocialUploadRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialUploadResponse:
|
||||
"""
|
||||
소셜 플랫폼에 영상 업로드 요청
|
||||
|
||||
백그라운드에서 영상을 다운로드하고 소셜 플랫폼에 업로드합니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[UPLOAD_API] 업로드 요청 - "
|
||||
f"user_uuid: {current_user.user_uuid}, "
|
||||
f"video_id: {body.video_id}, "
|
||||
f"social_account_id: {body.social_account_id}"
|
||||
)
|
||||
|
||||
# 1. 영상 조회 및 검증
|
||||
video_result = await session.execute(
|
||||
select(Video).where(Video.id == body.video_id)
|
||||
)
|
||||
video = video_result.scalar_one_or_none()
|
||||
|
||||
if not video:
|
||||
logger.warning(f"[UPLOAD_API] 영상 없음 - video_id: {body.video_id}")
|
||||
raise VideoNotFoundError(video_id=body.video_id)
|
||||
|
||||
if not video.result_movie_url:
|
||||
logger.warning(f"[UPLOAD_API] 영상 URL 없음 - video_id: {body.video_id}")
|
||||
raise VideoNotFoundError(
|
||||
video_id=body.video_id,
|
||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
||||
)
|
||||
|
||||
# 2. 소셜 계정 조회 (social_account_id로 직접 조회, 소유권 검증 포함)
|
||||
account = await social_account_service.get_account_by_id(
|
||||
user_uuid=current_user.user_uuid,
|
||||
account_id=body.social_account_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if not account:
|
||||
logger.warning(
|
||||
f"[UPLOAD_API] 연동 계정 없음 - "
|
||||
f"user_uuid: {current_user.user_uuid}, social_account_id: {body.social_account_id}"
|
||||
)
|
||||
raise SocialAccountNotFoundError()
|
||||
|
||||
# 3. 기존 업로드 확인 (동일 video + account 조합)
|
||||
existing_result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.video_id == body.video_id,
|
||||
SocialUpload.social_account_id == account.id,
|
||||
SocialUpload.status.in_(
|
||||
[UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]
|
||||
),
|
||||
)
|
||||
)
|
||||
existing_upload = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing_upload:
|
||||
logger.info(
|
||||
f"[UPLOAD_API] 진행 중인 업로드 존재 - upload_id: {existing_upload.id}"
|
||||
)
|
||||
return SocialUploadResponse(
|
||||
success=True,
|
||||
upload_id=existing_upload.id,
|
||||
platform=account.platform,
|
||||
status=existing_upload.status,
|
||||
message="이미 업로드가 진행 중입니다.",
|
||||
)
|
||||
|
||||
# 4. 새 업로드 레코드 생성
|
||||
social_upload = SocialUpload(
|
||||
user_uuid=current_user.user_uuid,
|
||||
video_id=body.video_id,
|
||||
social_account_id=account.id,
|
||||
platform=account.platform, # 계정의 플랫폼 정보 사용
|
||||
status=UploadStatus.PENDING.value,
|
||||
upload_progress=0,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
tags=body.tags,
|
||||
privacy_status=body.privacy_status.value,
|
||||
platform_options={
|
||||
**(body.platform_options or {}),
|
||||
"scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None,
|
||||
},
|
||||
retry_count=0,
|
||||
)
|
||||
|
||||
session.add(social_upload)
|
||||
await session.commit()
|
||||
await session.refresh(social_upload)
|
||||
|
||||
logger.info(
|
||||
f"[UPLOAD_API] 업로드 레코드 생성 - "
|
||||
f"upload_id: {social_upload.id}, video_id: {body.video_id}, platform: {account.platform}"
|
||||
)
|
||||
|
||||
# 5. 백그라운드 태스크 등록
|
||||
background_tasks.add_task(process_social_upload, social_upload.id)
|
||||
|
||||
return SocialUploadResponse(
|
||||
success=True,
|
||||
upload_id=social_upload.id,
|
||||
platform=account.platform,
|
||||
status=social_upload.status,
|
||||
message="업로드 요청이 접수되었습니다.",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{upload_id}/status",
|
||||
response_model=SocialUploadStatusResponse,
|
||||
summary="업로드 상태 조회",
|
||||
description="특정 업로드 작업의 상태를 조회합니다.",
|
||||
)
|
||||
async def get_upload_status(
|
||||
upload_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialUploadStatusResponse:
|
||||
"""
|
||||
업로드 상태 조회
|
||||
"""
|
||||
logger.info(f"[UPLOAD_API] 상태 조회 - upload_id: {upload_id}")
|
||||
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.id == upload_id,
|
||||
SocialUpload.user_uuid == current_user.user_uuid,
|
||||
)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if not upload:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="업로드 정보를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
return SocialUploadStatusResponse(
|
||||
upload_id=upload.id,
|
||||
video_id=upload.video_id,
|
||||
platform=upload.platform,
|
||||
status=UploadStatus(upload.status),
|
||||
upload_progress=upload.upload_progress,
|
||||
title=upload.title,
|
||||
platform_video_id=upload.platform_video_id,
|
||||
platform_url=upload.platform_url,
|
||||
error_message=upload.error_message,
|
||||
retry_count=upload.retry_count,
|
||||
created_at=upload.created_at,
|
||||
uploaded_at=upload.uploaded_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/history",
|
||||
response_model=SocialUploadHistoryResponse,
|
||||
summary="업로드 이력 조회",
|
||||
description="사용자의 소셜 미디어 업로드 이력을 조회합니다.",
|
||||
)
|
||||
async def get_upload_history(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"),
|
||||
status: Optional[UploadStatus] = Query(None, description="상태 필터"),
|
||||
page: int = Query(1, ge=1, description="페이지 번호"),
|
||||
size: int = Query(20, ge=1, le=100, description="페이지 크기"),
|
||||
) -> SocialUploadHistoryResponse:
|
||||
"""
|
||||
업로드 이력 조회
|
||||
"""
|
||||
logger.info(
|
||||
f"[UPLOAD_API] 이력 조회 - "
|
||||
f"user_uuid: {current_user.user_uuid}, page: {page}, size: {size}"
|
||||
)
|
||||
|
||||
# 기본 쿼리
|
||||
query = select(SocialUpload).where(
|
||||
SocialUpload.user_uuid == current_user.user_uuid
|
||||
)
|
||||
|
||||
count_query = select(func.count(SocialUpload.id)).where(
|
||||
SocialUpload.user_uuid == current_user.user_uuid
|
||||
)
|
||||
|
||||
# 필터 적용
|
||||
if platform:
|
||||
query = query.where(SocialUpload.platform == platform.value)
|
||||
count_query = count_query.where(SocialUpload.platform == platform.value)
|
||||
|
||||
if status:
|
||||
query = query.where(SocialUpload.status == status.value)
|
||||
count_query = count_query.where(SocialUpload.status == status.value)
|
||||
|
||||
# 총 개수 조회
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 페이지네이션 적용
|
||||
query = (
|
||||
query.order_by(SocialUpload.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
uploads = result.scalars().all()
|
||||
|
||||
items = [
|
||||
SocialUploadHistoryItem(
|
||||
upload_id=upload.id,
|
||||
video_id=upload.video_id,
|
||||
platform=upload.platform,
|
||||
status=upload.status,
|
||||
title=upload.title,
|
||||
platform_url=upload.platform_url,
|
||||
created_at=upload.created_at,
|
||||
uploaded_at=upload.uploaded_at,
|
||||
)
|
||||
for upload in uploads
|
||||
]
|
||||
|
||||
return SocialUploadHistoryResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{upload_id}/retry",
|
||||
response_model=SocialUploadResponse,
|
||||
summary="업로드 재시도",
|
||||
description="실패한 업로드를 재시도합니다.",
|
||||
)
|
||||
async def retry_upload(
|
||||
upload_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialUploadResponse:
|
||||
"""
|
||||
업로드 재시도
|
||||
|
||||
실패한 업로드를 다시 시도합니다.
|
||||
"""
|
||||
logger.info(f"[UPLOAD_API] 재시도 요청 - upload_id: {upload_id}")
|
||||
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.id == upload_id,
|
||||
SocialUpload.user_uuid == current_user.user_uuid,
|
||||
)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if not upload:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="업로드 정보를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
if upload.status not in [UploadStatus.FAILED.value, UploadStatus.CANCELLED.value]:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="실패하거나 취소된 업로드만 재시도할 수 있습니다.",
|
||||
)
|
||||
|
||||
# 상태 초기화
|
||||
upload.status = UploadStatus.PENDING.value
|
||||
upload.upload_progress = 0
|
||||
upload.error_message = None
|
||||
await session.commit()
|
||||
|
||||
# 백그라운드 태스크 등록
|
||||
background_tasks.add_task(process_social_upload, upload.id)
|
||||
|
||||
return SocialUploadResponse(
|
||||
success=True,
|
||||
upload_id=upload.id,
|
||||
platform=upload.platform,
|
||||
status=upload.status,
|
||||
message="업로드 재시도가 요청되었습니다.",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{upload_id}",
|
||||
response_model=MessageResponse,
|
||||
summary="업로드 취소",
|
||||
description="대기 중인 업로드를 취소합니다.",
|
||||
)
|
||||
async def cancel_upload(
|
||||
upload_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
업로드 취소
|
||||
|
||||
대기 중인 업로드를 취소합니다.
|
||||
이미 진행 중이거나 완료된 업로드는 취소할 수 없습니다.
|
||||
"""
|
||||
logger.info(f"[UPLOAD_API] 취소 요청 - upload_id: {upload_id}")
|
||||
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.id == upload_id,
|
||||
SocialUpload.user_uuid == current_user.user_uuid,
|
||||
)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if not upload:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="업로드 정보를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
if upload.status != UploadStatus.PENDING.value:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="대기 중인 업로드만 취소할 수 있습니다.",
|
||||
)
|
||||
|
||||
upload.status = UploadStatus.CANCELLED.value
|
||||
await session.commit()
|
||||
|
||||
return MessageResponse(
|
||||
success=True,
|
||||
message="업로드가 취소되었습니다.",
|
||||
)
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
"""
|
||||
Social Media Constants
|
||||
|
||||
소셜 미디어 플랫폼 관련 상수 및 Enum을 정의합니다.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SocialPlatform(str, Enum):
|
||||
"""지원하는 소셜 미디어 플랫폼"""
|
||||
|
||||
YOUTUBE = "youtube"
|
||||
INSTAGRAM = "instagram"
|
||||
FACEBOOK = "facebook"
|
||||
TIKTOK = "tiktok"
|
||||
|
||||
|
||||
class UploadStatus(str, Enum):
|
||||
"""업로드 상태"""
|
||||
|
||||
PENDING = "pending" # 업로드 대기 중
|
||||
UPLOADING = "uploading" # 업로드 진행 중
|
||||
PROCESSING = "processing" # 플랫폼에서 처리 중 (인코딩 등)
|
||||
COMPLETED = "completed" # 업로드 완료
|
||||
FAILED = "failed" # 업로드 실패
|
||||
CANCELLED = "cancelled" # 취소됨
|
||||
|
||||
|
||||
class PrivacyStatus(str, Enum):
|
||||
"""영상 공개 상태"""
|
||||
|
||||
PUBLIC = "public" # 전체 공개
|
||||
UNLISTED = "unlisted" # 일부 공개 (링크 있는 사람만)
|
||||
PRIVATE = "private" # 비공개
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 플랫폼별 설정
|
||||
# =============================================================================
|
||||
|
||||
PLATFORM_CONFIG = {
|
||||
SocialPlatform.YOUTUBE: {
|
||||
"name": "YouTube",
|
||||
"display_name": "유튜브",
|
||||
"max_file_size_mb": 256000, # 256GB
|
||||
"supported_formats": ["mp4", "mov", "avi", "wmv", "flv", "3gp", "webm"],
|
||||
"max_title_length": 100,
|
||||
"max_description_length": 5000,
|
||||
"max_tags": 500,
|
||||
"supported_privacy": ["public", "unlisted", "private"],
|
||||
"requires_channel": True,
|
||||
},
|
||||
SocialPlatform.INSTAGRAM: {
|
||||
"name": "Instagram",
|
||||
"display_name": "인스타그램",
|
||||
"max_file_size_mb": 4096, # 4GB (Reels)
|
||||
"supported_formats": ["mp4", "mov"],
|
||||
"max_duration_seconds": 90, # Reels 최대 90초
|
||||
"min_duration_seconds": 3,
|
||||
"aspect_ratios": ["9:16", "1:1", "4:5"],
|
||||
"max_caption_length": 2200,
|
||||
"requires_business_account": True,
|
||||
},
|
||||
SocialPlatform.FACEBOOK: {
|
||||
"name": "Facebook",
|
||||
"display_name": "페이스북",
|
||||
"max_file_size_mb": 10240, # 10GB
|
||||
"supported_formats": ["mp4", "mov"],
|
||||
"max_duration_seconds": 14400, # 4시간
|
||||
"max_title_length": 255,
|
||||
"max_description_length": 5000,
|
||||
"requires_page": True,
|
||||
},
|
||||
SocialPlatform.TIKTOK: {
|
||||
"name": "TikTok",
|
||||
"display_name": "틱톡",
|
||||
"max_file_size_mb": 4096, # 4GB
|
||||
"supported_formats": ["mp4", "mov", "webm"],
|
||||
"max_duration_seconds": 600, # 10분
|
||||
"min_duration_seconds": 1,
|
||||
"max_title_length": 150,
|
||||
"requires_business_account": True,
|
||||
},
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# YouTube OAuth Scopes
|
||||
# =============================================================================
|
||||
|
||||
YOUTUBE_SCOPES = [
|
||||
"https://www.googleapis.com/auth/youtube.upload", # 영상 업로드
|
||||
"https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기
|
||||
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Instagram/Facebook OAuth Scopes (추후 구현)
|
||||
# =============================================================================
|
||||
|
||||
# INSTAGRAM_SCOPES = [
|
||||
# "instagram_basic",
|
||||
# "instagram_content_publish",
|
||||
# "pages_read_engagement",
|
||||
# "business_management",
|
||||
# ]
|
||||
|
||||
# FACEBOOK_SCOPES = [
|
||||
# "pages_manage_posts",
|
||||
# "pages_read_engagement",
|
||||
# "publish_video",
|
||||
# "pages_show_list",
|
||||
# ]
|
||||
|
||||
# =============================================================================
|
||||
# TikTok OAuth Scopes (추후 구현)
|
||||
# =============================================================================
|
||||
|
||||
# TIKTOK_SCOPES = [
|
||||
# "user.info.basic",
|
||||
# "video.upload",
|
||||
# "video.publish",
|
||||
# ]
|
||||
|
|
@ -1,330 +0,0 @@
|
|||
"""
|
||||
Social Media Exceptions
|
||||
|
||||
소셜 미디어 연동 관련 예외 클래스를 정의합니다.
|
||||
"""
|
||||
|
||||
from fastapi import status
|
||||
|
||||
|
||||
class SocialException(Exception):
|
||||
"""소셜 미디어 기본 예외"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code: str = "SOCIAL_ERROR",
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OAuth 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OAuthException(SocialException):
|
||||
"""OAuth 관련 예외 기본 클래스"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "OAuth 인증 중 오류가 발생했습니다.",
|
||||
status_code: int = status.HTTP_401_UNAUTHORIZED,
|
||||
code: str = "OAUTH_ERROR",
|
||||
):
|
||||
super().__init__(message, status_code, code)
|
||||
|
||||
|
||||
class InvalidStateError(OAuthException):
|
||||
"""CSRF state 토큰 불일치"""
|
||||
|
||||
def __init__(self, message: str = "유효하지 않은 인증 세션입니다. 다시 시도해주세요."):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="INVALID_STATE",
|
||||
)
|
||||
|
||||
|
||||
class OAuthStateExpiredError(OAuthException):
|
||||
"""OAuth state 토큰 만료"""
|
||||
|
||||
def __init__(self, message: str = "인증 세션이 만료되었습니다. 다시 시도해주세요."):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="STATE_EXPIRED",
|
||||
)
|
||||
|
||||
|
||||
class OAuthTokenError(OAuthException):
|
||||
"""OAuth 토큰 교환 실패"""
|
||||
|
||||
def __init__(self, platform: str, message: str = ""):
|
||||
error_message = f"{platform} 토큰 발급에 실패했습니다."
|
||||
if message:
|
||||
error_message += f" ({message})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_EXCHANGE_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class TokenRefreshError(OAuthException):
|
||||
"""토큰 갱신 실패"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"{platform} 토큰 갱신에 실패했습니다. 재연동이 필요합니다.",
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_REFRESH_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class OAuthCodeExchangeError(OAuthException):
|
||||
"""OAuth 인가 코드 교환 실패"""
|
||||
|
||||
def __init__(self, platform: str, detail: str = ""):
|
||||
error_message = f"{platform} 인가 코드 교환에 실패했습니다."
|
||||
if detail:
|
||||
error_message += f" ({detail})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="CODE_EXCHANGE_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class OAuthTokenRefreshError(OAuthException):
|
||||
"""OAuth 토큰 갱신 실패"""
|
||||
|
||||
def __init__(self, platform: str, detail: str = ""):
|
||||
error_message = f"{platform} 토큰 갱신에 실패했습니다."
|
||||
if detail:
|
||||
error_message += f" ({detail})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_REFRESH_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class TokenExpiredError(OAuthException):
|
||||
"""토큰 만료"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"{platform} 인증이 만료되었습니다. 재연동이 필요합니다.",
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_EXPIRED",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 소셜 계정 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SocialAccountException(SocialException):
|
||||
"""소셜 계정 관련 예외 기본 클래스"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SocialAccountNotFoundError(SocialAccountException):
|
||||
"""연동된 계정을 찾을 수 없음"""
|
||||
|
||||
def __init__(self, platform: str = ""):
|
||||
message = f"{platform} 계정이 연동되어 있지 않습니다." if platform else "연동된 소셜 계정이 없습니다."
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="SOCIAL_ACCOUNT_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class SocialAccountAlreadyExistsError(SocialAccountException):
|
||||
"""이미 연동된 계정이 존재함"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"이미 {platform} 계정이 연동되어 있습니다.",
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
code="SOCIAL_ACCOUNT_EXISTS",
|
||||
)
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
SocialAccountAlreadyConnectedError = SocialAccountAlreadyExistsError
|
||||
|
||||
|
||||
class SocialAccountInactiveError(SocialAccountException):
|
||||
"""비활성화된 소셜 계정"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"{platform} 계정이 비활성화 상태입니다. 재연동이 필요합니다.",
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
code="SOCIAL_ACCOUNT_INACTIVE",
|
||||
)
|
||||
|
||||
|
||||
class SocialAccountError(SocialAccountException):
|
||||
"""소셜 계정 일반 오류"""
|
||||
|
||||
def __init__(self, platform: str, detail: str = ""):
|
||||
error_message = f"{platform} 계정 처리 중 오류가 발생했습니다."
|
||||
if detail:
|
||||
error_message += f" ({detail})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="SOCIAL_ACCOUNT_ERROR",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 업로드 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class UploadException(SocialException):
|
||||
"""업로드 관련 예외 기본 클래스"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UploadError(UploadException):
|
||||
"""업로드 일반 오류"""
|
||||
|
||||
def __init__(self, platform: str, detail: str = ""):
|
||||
error_message = f"{platform} 업로드 중 오류가 발생했습니다."
|
||||
if detail:
|
||||
error_message += f" ({detail})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code="UPLOAD_ERROR",
|
||||
)
|
||||
|
||||
|
||||
class UploadValidationError(UploadException):
|
||||
"""업로드 유효성 검사 실패"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="UPLOAD_VALIDATION_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class VideoNotFoundError(UploadException):
|
||||
"""영상을 찾을 수 없음"""
|
||||
|
||||
def __init__(self, video_id: int, detail: str = ""):
|
||||
message = f"영상을 찾을 수 없습니다. (video_id: {video_id})"
|
||||
if detail:
|
||||
message = detail
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="VIDEO_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class VideoNotReadyError(UploadException):
|
||||
"""영상이 준비되지 않음"""
|
||||
|
||||
def __init__(self, video_id: int):
|
||||
super().__init__(
|
||||
message=f"영상이 아직 준비되지 않았습니다. (video_id: {video_id})",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="VIDEO_NOT_READY",
|
||||
)
|
||||
|
||||
|
||||
class UploadFailedError(UploadException):
|
||||
"""업로드 실패"""
|
||||
|
||||
def __init__(self, platform: str, message: str = ""):
|
||||
error_message = f"{platform} 업로드에 실패했습니다."
|
||||
if message:
|
||||
error_message += f" ({message})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code="UPLOAD_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class UploadQuotaExceededError(UploadException):
|
||||
"""업로드 할당량 초과"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"{platform} 일일 업로드 할당량이 초과되었습니다.",
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
code="UPLOAD_QUOTA_EXCEEDED",
|
||||
)
|
||||
|
||||
|
||||
class UploadNotFoundError(UploadException):
|
||||
"""업로드 기록을 찾을 수 없음"""
|
||||
|
||||
def __init__(self, upload_id: int):
|
||||
super().__init__(
|
||||
message=f"업로드 기록을 찾을 수 없습니다. (upload_id: {upload_id})",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="UPLOAD_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 플랫폼 API 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PlatformAPIError(SocialException):
|
||||
"""플랫폼 API 호출 오류"""
|
||||
|
||||
def __init__(self, platform: str, message: str = ""):
|
||||
error_message = f"{platform} API 호출 중 오류가 발생했습니다."
|
||||
if message:
|
||||
error_message += f" ({message})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
code="PLATFORM_API_ERROR",
|
||||
)
|
||||
|
||||
|
||||
class RateLimitError(PlatformAPIError):
|
||||
"""API 요청 한도 초과"""
|
||||
|
||||
def __init__(self, platform: str, retry_after: int | None = None):
|
||||
message = f"{platform} API 요청 한도가 초과되었습니다."
|
||||
if retry_after:
|
||||
message += f" {retry_after}초 후에 다시 시도해주세요."
|
||||
super().__init__(
|
||||
platform=platform,
|
||||
message=message,
|
||||
)
|
||||
self.retry_after = retry_after
|
||||
self.code = "RATE_LIMIT_EXCEEDED"
|
||||
|
||||
|
||||
class UnsupportedPlatformError(SocialException):
|
||||
"""지원하지 않는 플랫폼"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"지원하지 않는 플랫폼입니다: {platform}",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="UNSUPPORTED_PLATFORM",
|
||||
)
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
"""
|
||||
Social Media Models
|
||||
|
||||
소셜 미디어 업로드 관련 SQLAlchemy 모델을 정의합니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.mysql import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.user.models import SocialAccount
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class SocialUpload(Base):
|
||||
"""
|
||||
소셜 미디어 업로드 기록 테이블
|
||||
|
||||
영상의 소셜 미디어 플랫폼별 업로드 상태를 추적합니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
||||
video_id: Video 외래키
|
||||
social_account_id: SocialAccount 외래키
|
||||
platform: 플랫폼 구분 (youtube, instagram, facebook, tiktok)
|
||||
status: 업로드 상태 (pending, uploading, processing, completed, failed)
|
||||
upload_progress: 업로드 진행률 (0-100)
|
||||
platform_video_id: 플랫폼에서 부여한 영상 ID
|
||||
platform_url: 플랫폼에서의 영상 URL
|
||||
title: 영상 제목
|
||||
description: 영상 설명
|
||||
tags: 태그 목록 (JSON)
|
||||
privacy_status: 공개 상태 (public, unlisted, private)
|
||||
platform_options: 플랫폼별 추가 옵션 (JSON)
|
||||
error_message: 에러 메시지 (실패 시)
|
||||
retry_count: 재시도 횟수
|
||||
uploaded_at: 업로드 완료 시간
|
||||
created_at: 생성 일시
|
||||
updated_at: 수정 일시
|
||||
|
||||
Relationships:
|
||||
video: 연결된 Video
|
||||
social_account: 연결된 SocialAccount
|
||||
"""
|
||||
|
||||
__tablename__ = "social_upload"
|
||||
__table_args__ = (
|
||||
Index("idx_social_upload_user_uuid", "user_uuid"),
|
||||
Index("idx_social_upload_video_id", "video_id"),
|
||||
Index("idx_social_upload_social_account_id", "social_account_id"),
|
||||
Index("idx_social_upload_platform", "platform"),
|
||||
Index("idx_social_upload_status", "status"),
|
||||
Index("idx_social_upload_created_at", "created_at"),
|
||||
Index(
|
||||
"uq_social_upload_video_platform",
|
||||
"video_id",
|
||||
"social_account_id",
|
||||
unique=True,
|
||||
),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_unicode_ci",
|
||||
},
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 기본 식별자
|
||||
# ==========================================================================
|
||||
id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
autoincrement=True,
|
||||
comment="고유 식별자",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 관계 필드
|
||||
# ==========================================================================
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="사용자 UUID (User.user_uuid 참조)",
|
||||
)
|
||||
|
||||
video_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("video.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="Video 외래키",
|
||||
)
|
||||
|
||||
social_account_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("social_account.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="SocialAccount 외래키",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 플랫폼 정보
|
||||
# ==========================================================================
|
||||
platform: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
comment="플랫폼 구분 (youtube, instagram, facebook, tiktok)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 업로드 상태
|
||||
# ==========================================================================
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="pending",
|
||||
comment="업로드 상태 (pending, uploading, processing, completed, failed)",
|
||||
)
|
||||
|
||||
upload_progress: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="업로드 진행률 (0-100)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 플랫폼 결과
|
||||
# ==========================================================================
|
||||
platform_video_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="플랫폼에서 부여한 영상 ID",
|
||||
)
|
||||
|
||||
platform_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
comment="플랫폼에서의 영상 URL",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 메타데이터
|
||||
# ==========================================================================
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
comment="영상 제목",
|
||||
)
|
||||
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="영상 설명",
|
||||
)
|
||||
|
||||
tags: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="태그 목록 (JSON 배열)",
|
||||
)
|
||||
|
||||
privacy_status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="private",
|
||||
comment="공개 상태 (public, unlisted, private)",
|
||||
)
|
||||
|
||||
platform_options: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="플랫폼별 추가 옵션 (JSON)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 에러 정보
|
||||
# ==========================================================================
|
||||
error_message: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="에러 메시지 (실패 시)",
|
||||
)
|
||||
|
||||
retry_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="재시도 횟수",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 시간 정보
|
||||
# ==========================================================================
|
||||
uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="업로드 완료 시간",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
comment="수정 일시",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Relationships
|
||||
# ==========================================================================
|
||||
video: Mapped["Video"] = relationship(
|
||||
"Video",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
social_account: Mapped["SocialAccount"] = relationship(
|
||||
"SocialAccount",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<SocialUpload("
|
||||
f"id={self.id}, "
|
||||
f"platform='{self.platform}', "
|
||||
f"status='{self.status}', "
|
||||
f"video_id={self.video_id}"
|
||||
f")>"
|
||||
)
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
"""
|
||||
Social OAuth Module
|
||||
|
||||
소셜 미디어 OAuth 클라이언트 모듈입니다.
|
||||
"""
|
||||
|
||||
from app.social.constants import SocialPlatform
|
||||
from app.social.oauth.base import BaseOAuthClient
|
||||
|
||||
|
||||
def get_oauth_client(platform: SocialPlatform) -> BaseOAuthClient:
|
||||
"""
|
||||
플랫폼에 맞는 OAuth 클라이언트 반환
|
||||
|
||||
Args:
|
||||
platform: 소셜 플랫폼
|
||||
|
||||
Returns:
|
||||
BaseOAuthClient: OAuth 클라이언트 인스턴스
|
||||
|
||||
Raises:
|
||||
ValueError: 지원하지 않는 플랫폼인 경우
|
||||
"""
|
||||
if platform == SocialPlatform.YOUTUBE:
|
||||
from app.social.oauth.youtube import YouTubeOAuthClient
|
||||
|
||||
return YouTubeOAuthClient()
|
||||
|
||||
# 추후 확장
|
||||
# elif platform == SocialPlatform.INSTAGRAM:
|
||||
# from app.social.oauth.instagram import InstagramOAuthClient
|
||||
# return InstagramOAuthClient()
|
||||
# elif platform == SocialPlatform.FACEBOOK:
|
||||
# from app.social.oauth.facebook import FacebookOAuthClient
|
||||
# return FacebookOAuthClient()
|
||||
# elif platform == SocialPlatform.TIKTOK:
|
||||
# from app.social.oauth.tiktok import TikTokOAuthClient
|
||||
# return TikTokOAuthClient()
|
||||
|
||||
raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseOAuthClient",
|
||||
"get_oauth_client",
|
||||
]
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
"""
|
||||
Base OAuth Client
|
||||
|
||||
소셜 미디어 OAuth 클라이언트의 추상 기본 클래스입니다.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from app.social.constants import SocialPlatform
|
||||
from app.social.schemas import OAuthTokenResponse, PlatformUserInfo
|
||||
|
||||
|
||||
class BaseOAuthClient(ABC):
|
||||
"""
|
||||
소셜 미디어 OAuth 클라이언트 추상 기본 클래스
|
||||
|
||||
모든 플랫폼별 OAuth 클라이언트는 이 클래스를 상속받아 구현합니다.
|
||||
|
||||
Attributes:
|
||||
platform: 소셜 플랫폼 종류
|
||||
"""
|
||||
|
||||
platform: SocialPlatform
|
||||
|
||||
@abstractmethod
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""
|
||||
OAuth 인증 URL 생성
|
||||
|
||||
Args:
|
||||
state: CSRF 방지용 state 토큰
|
||||
|
||||
Returns:
|
||||
str: OAuth 인증 페이지 URL
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""
|
||||
인가 코드로 액세스 토큰 교환
|
||||
|
||||
Args:
|
||||
code: OAuth 인가 코드
|
||||
|
||||
Returns:
|
||||
OAuthTokenResponse: 액세스 토큰 및 리프레시 토큰
|
||||
|
||||
Raises:
|
||||
OAuthCodeExchangeError: 토큰 교환 실패 시
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def refresh_token(self, refresh_token: str) -> OAuthTokenResponse:
|
||||
"""
|
||||
리프레시 토큰으로 액세스 토큰 갱신
|
||||
|
||||
Args:
|
||||
refresh_token: 리프레시 토큰
|
||||
|
||||
Returns:
|
||||
OAuthTokenResponse: 새 액세스 토큰
|
||||
|
||||
Raises:
|
||||
OAuthTokenRefreshError: 토큰 갱신 실패 시
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_info(self, access_token: str) -> PlatformUserInfo:
|
||||
"""
|
||||
플랫폼 사용자 정보 조회
|
||||
|
||||
Args:
|
||||
access_token: 액세스 토큰
|
||||
|
||||
Returns:
|
||||
PlatformUserInfo: 플랫폼 사용자 정보
|
||||
|
||||
Raises:
|
||||
SocialAccountError: 사용자 정보 조회 실패 시
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def revoke_token(self, token: str) -> bool:
|
||||
"""
|
||||
토큰 폐기 (연동 해제 시)
|
||||
|
||||
Args:
|
||||
token: 폐기할 토큰
|
||||
|
||||
Returns:
|
||||
bool: 폐기 성공 여부
|
||||
"""
|
||||
pass
|
||||
|
||||
def is_token_expired(self, expires_in: Optional[int]) -> bool:
|
||||
"""
|
||||
토큰 만료 여부 확인 (만료 10분 전이면 True)
|
||||
|
||||
Args:
|
||||
expires_in: 토큰 만료까지 남은 시간(초)
|
||||
|
||||
Returns:
|
||||
bool: 갱신 필요 여부
|
||||
"""
|
||||
if expires_in is None:
|
||||
return False
|
||||
# 만료 10분(600초) 전이면 갱신 필요
|
||||
return expires_in <= 600
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
"""
|
||||
YouTube OAuth Client
|
||||
|
||||
Google OAuth를 사용한 YouTube 인증 클라이언트입니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from config import social_oauth_settings
|
||||
from app.social.constants import SocialPlatform, YOUTUBE_SCOPES
|
||||
from app.social.exceptions import (
|
||||
OAuthCodeExchangeError,
|
||||
OAuthTokenRefreshError,
|
||||
SocialAccountError,
|
||||
)
|
||||
from app.social.oauth.base import BaseOAuthClient
|
||||
from app.social.schemas import OAuthTokenResponse, PlatformUserInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YouTubeOAuthClient(BaseOAuthClient):
|
||||
"""
|
||||
YouTube OAuth 클라이언트
|
||||
|
||||
Google OAuth 2.0을 사용하여 YouTube 계정 인증을 처리합니다.
|
||||
"""
|
||||
|
||||
platform = SocialPlatform.YOUTUBE
|
||||
|
||||
# Google OAuth 엔드포인트
|
||||
AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
YOUTUBE_CHANNEL_URL = "https://www.googleapis.com/youtube/v3/channels"
|
||||
REVOKE_URL = "https://oauth2.googleapis.com/revoke"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.client_id = social_oauth_settings.YOUTUBE_CLIENT_ID
|
||||
self.client_secret = social_oauth_settings.YOUTUBE_CLIENT_SECRET
|
||||
self.redirect_uri = social_oauth_settings.YOUTUBE_REDIRECT_URI
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""
|
||||
Google OAuth 인증 URL 생성
|
||||
|
||||
Args:
|
||||
state: CSRF 방지용 state 토큰
|
||||
|
||||
Returns:
|
||||
str: Google OAuth 인증 페이지 URL
|
||||
"""
|
||||
params = {
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(YOUTUBE_SCOPES),
|
||||
"access_type": "offline", # refresh_token 받기 위해 필요
|
||||
"prompt": "select_account", # 계정 선택만 표시 (이전 동의 유지)
|
||||
"state": state,
|
||||
}
|
||||
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
||||
logger.debug(f"[YOUTUBE_OAUTH] 인증 URL 생성: {url[:100]}...")
|
||||
return url
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""
|
||||
인가 코드로 액세스 토큰 교환
|
||||
|
||||
Args:
|
||||
code: OAuth 인가 코드
|
||||
|
||||
Returns:
|
||||
OAuthTokenResponse: 액세스 토큰 및 리프레시 토큰
|
||||
|
||||
Raises:
|
||||
OAuthCodeExchangeError: 토큰 교환 실패 시
|
||||
"""
|
||||
logger.info(f"[YOUTUBE_OAUTH] 토큰 교환 시작 - code: {code[:20]}...")
|
||||
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": self.redirect_uri,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
logger.info("[YOUTUBE_OAUTH] 토큰 교환 성공")
|
||||
logger.debug(
|
||||
f"[YOUTUBE_OAUTH] 토큰 정보 - "
|
||||
f"expires_in: {token_data.get('expires_in')}, "
|
||||
f"scope: {token_data.get('scope')}"
|
||||
)
|
||||
|
||||
return OAuthTokenResponse(
|
||||
access_token=token_data["access_token"],
|
||||
refresh_token=token_data.get("refresh_token"),
|
||||
expires_in=token_data["expires_in"],
|
||||
token_type=token_data.get("token_type", "Bearer"),
|
||||
scope=token_data.get("scope"),
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = e.response.text if e.response else str(e)
|
||||
logger.error(
|
||||
f"[YOUTUBE_OAUTH] 토큰 교환 실패 - "
|
||||
f"status: {e.response.status_code}, error: {error_detail}"
|
||||
)
|
||||
raise OAuthCodeExchangeError(
|
||||
platform=self.platform.value,
|
||||
detail=f"토큰 교환 실패: {error_detail}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[YOUTUBE_OAUTH] 토큰 교환 중 예외 발생: {e}")
|
||||
raise OAuthCodeExchangeError(
|
||||
platform=self.platform.value,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> OAuthTokenResponse:
|
||||
"""
|
||||
리프레시 토큰으로 액세스 토큰 갱신
|
||||
|
||||
Args:
|
||||
refresh_token: 리프레시 토큰
|
||||
|
||||
Returns:
|
||||
OAuthTokenResponse: 새 액세스 토큰
|
||||
|
||||
Raises:
|
||||
OAuthTokenRefreshError: 토큰 갱신 실패 시
|
||||
"""
|
||||
logger.info("[YOUTUBE_OAUTH] 토큰 갱신 시작")
|
||||
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": refresh_token,
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
logger.info("[YOUTUBE_OAUTH] 토큰 갱신 성공")
|
||||
|
||||
return OAuthTokenResponse(
|
||||
access_token=token_data["access_token"],
|
||||
refresh_token=refresh_token, # Google은 refresh_token 재발급 안함
|
||||
expires_in=token_data["expires_in"],
|
||||
token_type=token_data.get("token_type", "Bearer"),
|
||||
scope=token_data.get("scope"),
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = e.response.text if e.response else str(e)
|
||||
logger.error(
|
||||
f"[YOUTUBE_OAUTH] 토큰 갱신 실패 - "
|
||||
f"status: {e.response.status_code}, error: {error_detail}"
|
||||
)
|
||||
raise OAuthTokenRefreshError(
|
||||
platform=self.platform.value,
|
||||
detail=f"토큰 갱신 실패: {error_detail}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[YOUTUBE_OAUTH] 토큰 갱신 중 예외 발생: {e}")
|
||||
raise OAuthTokenRefreshError(
|
||||
platform=self.platform.value,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
async def get_user_info(self, access_token: str) -> PlatformUserInfo:
|
||||
"""
|
||||
YouTube 채널 정보 조회
|
||||
|
||||
Args:
|
||||
access_token: 액세스 토큰
|
||||
|
||||
Returns:
|
||||
PlatformUserInfo: YouTube 채널 정보
|
||||
|
||||
Raises:
|
||||
SocialAccountError: 정보 조회 실패 시
|
||||
"""
|
||||
logger.info("[YOUTUBE_OAUTH] 사용자/채널 정보 조회 시작")
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
# 1. Google 사용자 기본 정보 조회
|
||||
userinfo_response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers=headers,
|
||||
)
|
||||
userinfo_response.raise_for_status()
|
||||
userinfo = userinfo_response.json()
|
||||
|
||||
# 2. YouTube 채널 정보 조회
|
||||
channel_params = {
|
||||
"part": "snippet,statistics",
|
||||
"mine": "true",
|
||||
}
|
||||
channel_response = await client.get(
|
||||
self.YOUTUBE_CHANNEL_URL,
|
||||
headers=headers,
|
||||
params=channel_params,
|
||||
)
|
||||
channel_response.raise_for_status()
|
||||
channel_data = channel_response.json()
|
||||
|
||||
# 채널이 없는 경우
|
||||
if not channel_data.get("items"):
|
||||
logger.warning("[YOUTUBE_OAUTH] YouTube 채널 없음")
|
||||
raise SocialAccountError(
|
||||
platform=self.platform.value,
|
||||
detail="YouTube 채널이 없습니다. 채널을 먼저 생성해주세요.",
|
||||
)
|
||||
|
||||
channel = channel_data["items"][0]
|
||||
snippet = channel.get("snippet", {})
|
||||
statistics = channel.get("statistics", {})
|
||||
|
||||
logger.info(
|
||||
f"[YOUTUBE_OAUTH] 채널 정보 조회 성공 - "
|
||||
f"channel_id: {channel['id']}, "
|
||||
f"title: {snippet.get('title')}"
|
||||
)
|
||||
|
||||
return PlatformUserInfo(
|
||||
platform_user_id=channel["id"],
|
||||
username=snippet.get("customUrl"), # @username 형태
|
||||
display_name=snippet.get("title"),
|
||||
profile_image_url=snippet.get("thumbnails", {})
|
||||
.get("default", {})
|
||||
.get("url"),
|
||||
platform_data={
|
||||
"channel_id": channel["id"],
|
||||
"channel_title": snippet.get("title"),
|
||||
"channel_description": snippet.get("description"),
|
||||
"custom_url": snippet.get("customUrl"),
|
||||
"subscriber_count": statistics.get("subscriberCount"),
|
||||
"video_count": statistics.get("videoCount"),
|
||||
"view_count": statistics.get("viewCount"),
|
||||
"google_user_id": userinfo.get("id"),
|
||||
"google_email": userinfo.get("email"),
|
||||
},
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = e.response.text if e.response else str(e)
|
||||
logger.error(
|
||||
f"[YOUTUBE_OAUTH] 정보 조회 실패 - "
|
||||
f"status: {e.response.status_code}, error: {error_detail}"
|
||||
)
|
||||
raise SocialAccountError(
|
||||
platform=self.platform.value,
|
||||
detail=f"사용자 정보 조회 실패: {error_detail}",
|
||||
)
|
||||
except SocialAccountError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[YOUTUBE_OAUTH] 정보 조회 중 예외 발생: {e}")
|
||||
raise SocialAccountError(
|
||||
platform=self.platform.value,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
async def revoke_token(self, token: str) -> bool:
|
||||
"""
|
||||
토큰 폐기 (연동 해제 시)
|
||||
|
||||
Args:
|
||||
token: 폐기할 토큰 (access_token 또는 refresh_token)
|
||||
|
||||
Returns:
|
||||
bool: 폐기 성공 여부
|
||||
"""
|
||||
logger.info("[YOUTUBE_OAUTH] 토큰 폐기 시작")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
self.REVOKE_URL,
|
||||
data={"token": token},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("[YOUTUBE_OAUTH] 토큰 폐기 성공")
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"[YOUTUBE_OAUTH] 토큰 폐기 실패 - "
|
||||
f"status: {response.status_code}, body: {response.text}"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[YOUTUBE_OAUTH] 토큰 폐기 중 예외 발생: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
youtube_oauth_client = YouTubeOAuthClient()
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
"""
|
||||
Social Media Schemas
|
||||
|
||||
소셜 미디어 연동 관련 Pydantic 스키마를 정의합니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.social.constants import PrivacyStatus, SocialPlatform, UploadStatus
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OAuth 관련 스키마
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SocialConnectResponse(BaseModel):
|
||||
"""소셜 계정 연동 시작 응답"""
|
||||
|
||||
auth_url: str = Field(..., description="OAuth 인증 URL")
|
||||
state: str = Field(..., description="CSRF 방지용 state 토큰")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
|
||||
"state": "abc123xyz",
|
||||
"platform": "youtube",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SocialAccountResponse(BaseModel):
|
||||
"""연동된 소셜 계정 정보"""
|
||||
|
||||
id: int = Field(..., description="소셜 계정 ID")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
platform_user_id: str = Field(..., description="플랫폼 내 사용자 ID")
|
||||
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명")
|
||||
display_name: Optional[str] = Field(None, description="표시 이름")
|
||||
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
|
||||
is_active: bool = Field(..., description="활성화 상태")
|
||||
connected_at: datetime = Field(..., description="연동 일시")
|
||||
platform_data: Optional[dict[str, Any]] = Field(
|
||||
None, description="플랫폼별 추가 정보 (채널ID, 구독자 수 등)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"platform": "youtube",
|
||||
"platform_user_id": "UC1234567890",
|
||||
"platform_username": "my_channel",
|
||||
"display_name": "My Channel",
|
||||
"profile_image_url": "https://...",
|
||||
"is_active": True,
|
||||
"connected_at": "2024-01-15T12:00:00",
|
||||
"platform_data": {
|
||||
"channel_id": "UC1234567890",
|
||||
"channel_title": "My Channel",
|
||||
"subscriber_count": 1000,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SocialAccountListResponse(BaseModel):
|
||||
"""연동된 소셜 계정 목록 응답"""
|
||||
|
||||
accounts: list[SocialAccountResponse] = Field(..., description="연동 계정 목록")
|
||||
total: int = Field(..., description="총 연동 계정 수")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"accounts": [
|
||||
{
|
||||
"id": 1,
|
||||
"platform": "youtube",
|
||||
"platform_user_id": "UC1234567890",
|
||||
"platform_username": "my_channel",
|
||||
"display_name": "My Channel",
|
||||
"is_active": True,
|
||||
"connected_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 내부 사용 스키마 (OAuth 토큰 응답)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OAuthTokenResponse(BaseModel):
|
||||
"""OAuth 토큰 응답 (내부 사용)"""
|
||||
|
||||
access_token: str
|
||||
refresh_token: Optional[str] = None
|
||||
expires_in: int
|
||||
token_type: str = "Bearer"
|
||||
scope: Optional[str] = None
|
||||
|
||||
|
||||
class PlatformUserInfo(BaseModel):
|
||||
"""플랫폼 사용자 정보 (내부 사용)"""
|
||||
|
||||
platform_user_id: str
|
||||
username: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
profile_image_url: Optional[str] = None
|
||||
platform_data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 업로드 관련 스키마
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SocialUploadRequest(BaseModel):
|
||||
"""소셜 업로드 요청"""
|
||||
|
||||
video_id: int = Field(..., description="업로드할 영상 ID")
|
||||
social_account_id: int = Field(..., description="업로드할 소셜 계정 ID (연동 계정 목록의 id)")
|
||||
title: str = Field(..., min_length=1, max_length=100, description="영상 제목")
|
||||
description: Optional[str] = Field(
|
||||
None, max_length=5000, description="영상 설명"
|
||||
)
|
||||
tags: Optional[list[str]] = Field(None, description="태그 목록 (쉼표로 구분된 문자열도 가능)")
|
||||
privacy_status: PrivacyStatus = Field(
|
||||
default=PrivacyStatus.PRIVATE, description="공개 상태 (public, unlisted, private)"
|
||||
)
|
||||
scheduled_at: Optional[datetime] = Field(
|
||||
None, description="예약 게시 시간 (없으면 즉시 게시)"
|
||||
)
|
||||
platform_options: Optional[dict[str, Any]] = Field(
|
||||
None, description="플랫폼별 추가 옵션"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"video_id": 123,
|
||||
"social_account_id": 1,
|
||||
"title": "도그앤조이 애견펜션 2026.02.02",
|
||||
"description": "영상 설명입니다.",
|
||||
"tags": ["여행", "vlog", "애견펜션"],
|
||||
"privacy_status": "public",
|
||||
"scheduled_at": "2026-02-02T15:00:00",
|
||||
"platform_options": {
|
||||
"category_id": "22", # YouTube 카테고리
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SocialUploadResponse(BaseModel):
|
||||
"""소셜 업로드 요청 응답"""
|
||||
|
||||
success: bool = Field(..., description="요청 성공 여부")
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
status: str = Field(..., description="업로드 상태")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"upload_id": 456,
|
||||
"platform": "youtube",
|
||||
"status": "pending",
|
||||
"message": "업로드 요청이 접수되었습니다.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SocialUploadStatusResponse(BaseModel):
|
||||
"""업로드 상태 조회 응답"""
|
||||
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
video_id: int = Field(..., description="영상 ID")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
status: UploadStatus = Field(..., description="업로드 상태")
|
||||
upload_progress: int = Field(..., description="업로드 진행률 (0-100)")
|
||||
title: str = Field(..., description="영상 제목")
|
||||
platform_video_id: Optional[str] = Field(None, description="플랫폼 영상 ID")
|
||||
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지")
|
||||
retry_count: int = Field(default=0, description="재시도 횟수")
|
||||
created_at: datetime = Field(..., description="생성 일시")
|
||||
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"upload_id": 456,
|
||||
"video_id": 123,
|
||||
"platform": "youtube",
|
||||
"status": "completed",
|
||||
"upload_progress": 100,
|
||||
"title": "나의 첫 영상",
|
||||
"platform_video_id": "dQw4w9WgXcQ",
|
||||
"platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"error_message": None,
|
||||
"retry_count": 0,
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
"uploaded_at": "2024-01-15T12:05:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SocialUploadHistoryItem(BaseModel):
|
||||
"""업로드 이력 아이템"""
|
||||
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
video_id: int = Field(..., description="영상 ID")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
status: str = Field(..., description="업로드 상태")
|
||||
title: str = Field(..., description="영상 제목")
|
||||
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
||||
created_at: datetime = Field(..., description="생성 일시")
|
||||
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SocialUploadHistoryResponse(BaseModel):
|
||||
"""업로드 이력 목록 응답"""
|
||||
|
||||
items: list[SocialUploadHistoryItem] = Field(..., description="업로드 이력 목록")
|
||||
total: int = Field(..., description="전체 개수")
|
||||
page: int = Field(..., description="현재 페이지")
|
||||
size: int = Field(..., description="페이지 크기")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"items": [
|
||||
{
|
||||
"upload_id": 456,
|
||||
"video_id": 123,
|
||||
"platform": "youtube",
|
||||
"status": "completed",
|
||||
"title": "나의 첫 영상",
|
||||
"platform_url": "https://www.youtube.com/watch?v=xxx",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
"uploaded_at": "2024-01-15T12:05:00",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"size": 20,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 공통 응답 스키마
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""단순 메시지 응답"""
|
||||
|
||||
success: bool = Field(..., description="성공 여부")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "작업이 완료되었습니다.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -1,611 +0,0 @@
|
|||
"""
|
||||
Social Account Service
|
||||
|
||||
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from config import social_oauth_settings, db_settings
|
||||
from app.social.constants import SocialPlatform
|
||||
|
||||
# Social OAuth용 Redis 클라이언트 (DB 2 사용)
|
||||
redis_client = Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
db=2,
|
||||
decode_responses=True,
|
||||
)
|
||||
from app.social.exceptions import (
|
||||
InvalidStateError,
|
||||
OAuthStateExpiredError,
|
||||
SocialAccountAlreadyConnectedError,
|
||||
SocialAccountNotFoundError,
|
||||
)
|
||||
from app.social.oauth import get_oauth_client
|
||||
from app.social.schemas import (
|
||||
OAuthTokenResponse,
|
||||
PlatformUserInfo,
|
||||
SocialAccountResponse,
|
||||
SocialConnectResponse,
|
||||
)
|
||||
from app.user.models import SocialAccount
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SocialAccountService:
|
||||
"""
|
||||
소셜 계정 연동 서비스
|
||||
|
||||
OAuth 인증, 계정 연동/해제, 토큰 관리 기능을 제공합니다.
|
||||
"""
|
||||
|
||||
# Redis key prefix for OAuth state
|
||||
STATE_KEY_PREFIX = "social:oauth:state:"
|
||||
|
||||
async def start_connect(
|
||||
self,
|
||||
user_uuid: str,
|
||||
platform: SocialPlatform,
|
||||
) -> SocialConnectResponse:
|
||||
"""
|
||||
소셜 계정 연동 시작
|
||||
|
||||
OAuth 인증 URL을 생성하고 state 토큰을 저장합니다.
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
platform: 연동할 플랫폼
|
||||
|
||||
Returns:
|
||||
SocialConnectResponse: OAuth 인증 URL 및 state 토큰
|
||||
"""
|
||||
logger.info(
|
||||
f"[SOCIAL] 소셜 계정 연동 시작 - "
|
||||
f"user_uuid: {user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
# 1. state 토큰 생성 (CSRF 방지)
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# 2. state를 Redis에 저장 (user_uuid 포함)
|
||||
state_key = f"{self.STATE_KEY_PREFIX}{state}"
|
||||
state_data = {
|
||||
"user_uuid": user_uuid,
|
||||
"platform": platform.value,
|
||||
}
|
||||
await redis_client.setex(
|
||||
state_key,
|
||||
social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
|
||||
str(state_data),
|
||||
)
|
||||
logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}")
|
||||
|
||||
# 3. OAuth 클라이언트에서 인증 URL 생성
|
||||
oauth_client = get_oauth_client(platform)
|
||||
auth_url = oauth_client.get_authorization_url(state)
|
||||
|
||||
logger.info(f"[SOCIAL] OAuth URL 생성 완료 - platform: {platform.value}")
|
||||
|
||||
return SocialConnectResponse(
|
||||
auth_url=auth_url,
|
||||
state=state,
|
||||
platform=platform.value,
|
||||
)
|
||||
|
||||
async def handle_callback(
|
||||
self,
|
||||
code: str,
|
||||
state: str,
|
||||
session: AsyncSession,
|
||||
) -> SocialAccountResponse:
|
||||
"""
|
||||
OAuth 콜백 처리
|
||||
|
||||
인가 코드로 토큰을 교환하고 소셜 계정을 저장합니다.
|
||||
|
||||
Args:
|
||||
code: OAuth 인가 코드
|
||||
state: CSRF 방지용 state 토큰
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
SocialAccountResponse: 연동된 소셜 계정 정보
|
||||
|
||||
Raises:
|
||||
InvalidStateError: state 토큰이 유효하지 않은 경우
|
||||
OAuthStateExpiredError: state 토큰이 만료된 경우
|
||||
SocialAccountAlreadyConnectedError: 이미 연동된 계정인 경우
|
||||
"""
|
||||
logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...")
|
||||
|
||||
# 1. state 검증 및 사용자 정보 추출
|
||||
state_key = f"{self.STATE_KEY_PREFIX}{state}"
|
||||
state_data_str = await redis_client.get(state_key)
|
||||
|
||||
if state_data_str is None:
|
||||
logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...")
|
||||
raise OAuthStateExpiredError()
|
||||
|
||||
# state 데이터 파싱
|
||||
state_data = eval(state_data_str) # {"user_uuid": "...", "platform": "..."}
|
||||
user_uuid = state_data["user_uuid"]
|
||||
platform = SocialPlatform(state_data["platform"])
|
||||
|
||||
# state 삭제 (일회성)
|
||||
await redis_client.delete(state_key)
|
||||
logger.debug(f"[SOCIAL] state 토큰 사용 완료 및 삭제 - user_uuid: {user_uuid}")
|
||||
|
||||
# 2. OAuth 클라이언트로 토큰 교환
|
||||
oauth_client = get_oauth_client(platform)
|
||||
token_response = await oauth_client.exchange_code(code)
|
||||
|
||||
# 3. 플랫폼 사용자 정보 조회
|
||||
user_info = await oauth_client.get_user_info(token_response.access_token)
|
||||
|
||||
# 4. 기존 연동 확인 (소프트 삭제된 계정 포함)
|
||||
existing_account = await self._get_social_account(
|
||||
user_uuid=user_uuid,
|
||||
platform=platform,
|
||||
platform_user_id=user_info.platform_user_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if existing_account:
|
||||
# 기존 계정 존재 (활성화 또는 비활성화 상태)
|
||||
is_reactivation = False
|
||||
if existing_account.is_active and not existing_account.is_deleted:
|
||||
# 이미 활성화된 계정 - 토큰만 갱신
|
||||
logger.info(
|
||||
f"[SOCIAL] 기존 활성 계정 토큰 갱신 - "
|
||||
f"account_id: {existing_account.id}"
|
||||
)
|
||||
else:
|
||||
# 비활성화(소프트 삭제)된 계정 - 재활성화
|
||||
logger.info(
|
||||
f"[SOCIAL] 비활성 계정 재활성화 - "
|
||||
f"account_id: {existing_account.id}"
|
||||
)
|
||||
existing_account.is_active = True
|
||||
existing_account.is_deleted = False
|
||||
is_reactivation = True
|
||||
|
||||
# 토큰 및 정보 업데이트
|
||||
existing_account = await self._update_tokens(
|
||||
account=existing_account,
|
||||
token_response=token_response,
|
||||
user_info=user_info,
|
||||
session=session,
|
||||
update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트
|
||||
)
|
||||
return self._to_response(existing_account)
|
||||
|
||||
# 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만)
|
||||
social_account = await self._create_social_account(
|
||||
user_uuid=user_uuid,
|
||||
platform=platform,
|
||||
token_response=token_response,
|
||||
user_info=user_info,
|
||||
session=session,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[SOCIAL] 소셜 계정 연동 완료 - "
|
||||
f"account_id: {social_account.id}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
return self._to_response(social_account)
|
||||
|
||||
async def get_connected_accounts(
|
||||
self,
|
||||
user_uuid: str,
|
||||
session: AsyncSession,
|
||||
) -> list[SocialAccountResponse]:
|
||||
"""
|
||||
연동된 소셜 계정 목록 조회
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
list[SocialAccountResponse]: 연동된 계정 목록
|
||||
"""
|
||||
logger.info(f"[SOCIAL] 연동 계정 목록 조회 - user_uuid: {user_uuid}")
|
||||
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
accounts = result.scalars().all()
|
||||
|
||||
logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨")
|
||||
|
||||
return [self._to_response(account) for account in accounts]
|
||||
|
||||
async def get_account_by_platform(
|
||||
self,
|
||||
user_uuid: str,
|
||||
platform: SocialPlatform,
|
||||
session: AsyncSession,
|
||||
) -> Optional[SocialAccount]:
|
||||
"""
|
||||
특정 플랫폼의 연동 계정 조회
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
platform: 플랫폼
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
SocialAccount: 소셜 계정 (없으면 None)
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.platform == platform.value,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_account_by_id(
|
||||
self,
|
||||
user_uuid: str,
|
||||
account_id: int,
|
||||
session: AsyncSession,
|
||||
) -> Optional[SocialAccount]:
|
||||
"""
|
||||
account_id로 연동 계정 조회 (소유권 검증 포함)
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
account_id: 소셜 계정 ID
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
SocialAccount: 소셜 계정 (없으면 None)
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.id == account_id,
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def disconnect_by_account_id(
|
||||
self,
|
||||
user_uuid: str,
|
||||
account_id: int,
|
||||
session: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
account_id로 소셜 계정 연동 해제
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
account_id: 소셜 계정 ID
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
str: 연동 해제된 플랫폼 이름
|
||||
|
||||
Raises:
|
||||
SocialAccountNotFoundError: 연동된 계정이 없는 경우
|
||||
"""
|
||||
logger.info(
|
||||
f"[SOCIAL] 소셜 계정 연동 해제 시작 (by account_id) - "
|
||||
f"user_uuid: {user_uuid}, account_id: {account_id}"
|
||||
)
|
||||
|
||||
# 1. account_id로 계정 조회 (user_uuid 소유권 확인 포함)
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.id == account_id,
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
if account is None:
|
||||
logger.warning(
|
||||
f"[SOCIAL] 연동된 계정 없음 - "
|
||||
f"user_uuid: {user_uuid}, account_id: {account_id}"
|
||||
)
|
||||
raise SocialAccountNotFoundError()
|
||||
|
||||
# 2. 소프트 삭제
|
||||
platform = account.platform
|
||||
account.is_active = False
|
||||
account.is_deleted = True
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[SOCIAL] 소셜 계정 연동 해제 완료 - "
|
||||
f"account_id: {account.id}, platform: {platform}"
|
||||
)
|
||||
return platform
|
||||
|
||||
async def disconnect(
|
||||
self,
|
||||
user_uuid: str,
|
||||
platform: SocialPlatform,
|
||||
session: AsyncSession,
|
||||
) -> bool:
|
||||
"""
|
||||
소셜 계정 연동 해제 (platform 기준, deprecated)
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
platform: 연동 해제할 플랫폼
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
|
||||
Raises:
|
||||
SocialAccountNotFoundError: 연동된 계정이 없는 경우
|
||||
"""
|
||||
logger.info(
|
||||
f"[SOCIAL] 소셜 계정 연동 해제 시작 - "
|
||||
f"user_uuid: {user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
# 1. 연동된 계정 조회
|
||||
account = await self.get_account_by_platform(user_uuid, platform, session)
|
||||
|
||||
if account is None:
|
||||
logger.warning(
|
||||
f"[SOCIAL] 연동된 계정 없음 - "
|
||||
f"user_uuid: {user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
raise SocialAccountNotFoundError(platform=platform.value)
|
||||
|
||||
# 2. 소프트 삭제 (토큰 폐기하지 않음 - 재연결 시 동의 화면 스킵을 위해)
|
||||
# 참고: 사용자가 완전히 앱 연결을 끊으려면 Google 계정 설정에서 직접 해제해야 함
|
||||
account.is_active = False
|
||||
account.is_deleted = True
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"[SOCIAL] 소셜 계정 연동 해제 완료 - account_id: {account.id}")
|
||||
return True
|
||||
|
||||
async def ensure_valid_token(
|
||||
self,
|
||||
account: SocialAccount,
|
||||
session: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
토큰 유효성 확인 및 필요시 갱신
|
||||
|
||||
Args:
|
||||
account: 소셜 계정
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
str: 유효한 access_token
|
||||
"""
|
||||
# 만료 시간 확인 (만료 10분 전이면 갱신)
|
||||
if account.token_expires_at:
|
||||
buffer_time = datetime.now() + timedelta(minutes=10)
|
||||
if account.token_expires_at <= buffer_time:
|
||||
logger.info(
|
||||
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
|
||||
)
|
||||
return await self._refresh_account_token(account, session)
|
||||
|
||||
return account.access_token
|
||||
|
||||
async def _refresh_account_token(
|
||||
self,
|
||||
account: SocialAccount,
|
||||
session: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
계정 토큰 갱신
|
||||
|
||||
Args:
|
||||
account: 소셜 계정
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
str: 새 access_token
|
||||
"""
|
||||
if not account.refresh_token:
|
||||
logger.warning(
|
||||
f"[SOCIAL] refresh_token 없음, 갱신 불가 - account_id: {account.id}"
|
||||
)
|
||||
return account.access_token
|
||||
|
||||
platform = SocialPlatform(account.platform)
|
||||
oauth_client = get_oauth_client(platform)
|
||||
|
||||
token_response = await oauth_client.refresh_token(account.refresh_token)
|
||||
|
||||
# 토큰 업데이트
|
||||
account.access_token = token_response.access_token
|
||||
if token_response.refresh_token:
|
||||
account.refresh_token = token_response.refresh_token
|
||||
if token_response.expires_in:
|
||||
account.token_expires_at = datetime.now() + timedelta(
|
||||
seconds=token_response.expires_in
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}")
|
||||
return account.access_token
|
||||
|
||||
async def _get_social_account(
|
||||
self,
|
||||
user_uuid: str,
|
||||
platform: SocialPlatform,
|
||||
platform_user_id: str,
|
||||
session: AsyncSession,
|
||||
) -> Optional[SocialAccount]:
|
||||
"""
|
||||
소셜 계정 조회 (platform_user_id 포함)
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
platform: 플랫폼
|
||||
platform_user_id: 플랫폼 사용자 ID
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
SocialAccount: 소셜 계정 (없으면 None)
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.platform == platform.value,
|
||||
SocialAccount.platform_user_id == platform_user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _create_social_account(
|
||||
self,
|
||||
user_uuid: str,
|
||||
platform: SocialPlatform,
|
||||
token_response: OAuthTokenResponse,
|
||||
user_info: PlatformUserInfo,
|
||||
session: AsyncSession,
|
||||
) -> SocialAccount:
|
||||
"""
|
||||
새 소셜 계정 생성
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
platform: 플랫폼
|
||||
token_response: OAuth 토큰 응답
|
||||
user_info: 플랫폼 사용자 정보
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
SocialAccount: 생성된 소셜 계정
|
||||
"""
|
||||
# 토큰 만료 시간 계산
|
||||
token_expires_at = None
|
||||
if token_response.expires_in:
|
||||
token_expires_at = datetime.now() + timedelta(
|
||||
seconds=token_response.expires_in
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user_uuid=user_uuid,
|
||||
platform=platform.value,
|
||||
access_token=token_response.access_token,
|
||||
refresh_token=token_response.refresh_token,
|
||||
token_expires_at=token_expires_at,
|
||||
scope=token_response.scope,
|
||||
platform_user_id=user_info.platform_user_id,
|
||||
platform_username=user_info.username,
|
||||
platform_data={
|
||||
"display_name": user_info.display_name,
|
||||
"profile_image_url": user_info.profile_image_url,
|
||||
**user_info.platform_data,
|
||||
},
|
||||
is_active=True,
|
||||
is_deleted=False,
|
||||
)
|
||||
|
||||
session.add(social_account)
|
||||
await session.commit()
|
||||
await session.refresh(social_account)
|
||||
|
||||
return social_account
|
||||
|
||||
async def _update_tokens(
|
||||
self,
|
||||
account: SocialAccount,
|
||||
token_response: OAuthTokenResponse,
|
||||
user_info: PlatformUserInfo,
|
||||
session: AsyncSession,
|
||||
update_connected_at: bool = False,
|
||||
) -> SocialAccount:
|
||||
"""
|
||||
기존 계정 토큰 업데이트
|
||||
|
||||
Args:
|
||||
account: 기존 소셜 계정
|
||||
token_response: 새 OAuth 토큰 응답
|
||||
user_info: 플랫폼 사용자 정보
|
||||
session: DB 세션
|
||||
update_connected_at: 연결 시간 업데이트 여부 (재연결 시 True)
|
||||
|
||||
Returns:
|
||||
SocialAccount: 업데이트된 소셜 계정
|
||||
"""
|
||||
account.access_token = token_response.access_token
|
||||
if token_response.refresh_token:
|
||||
account.refresh_token = token_response.refresh_token
|
||||
if token_response.expires_in:
|
||||
account.token_expires_at = datetime.now() + timedelta(
|
||||
seconds=token_response.expires_in
|
||||
)
|
||||
if token_response.scope:
|
||||
account.scope = token_response.scope
|
||||
|
||||
# 플랫폼 정보 업데이트
|
||||
account.platform_username = user_info.username
|
||||
account.platform_data = {
|
||||
"display_name": user_info.display_name,
|
||||
"profile_image_url": user_info.profile_image_url,
|
||||
**user_info.platform_data,
|
||||
}
|
||||
|
||||
# 재연결 시 연결 시간 업데이트
|
||||
if update_connected_at:
|
||||
account.connected_at = datetime.now()
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(account)
|
||||
|
||||
return account
|
||||
|
||||
def _to_response(self, account: SocialAccount) -> SocialAccountResponse:
|
||||
"""
|
||||
SocialAccount를 SocialAccountResponse로 변환
|
||||
|
||||
Args:
|
||||
account: 소셜 계정
|
||||
|
||||
Returns:
|
||||
SocialAccountResponse: 응답 스키마
|
||||
"""
|
||||
platform_data = account.platform_data or {}
|
||||
|
||||
return SocialAccountResponse(
|
||||
id=account.id,
|
||||
platform=account.platform,
|
||||
platform_user_id=account.platform_user_id,
|
||||
platform_username=account.platform_username,
|
||||
display_name=platform_data.get("display_name"),
|
||||
profile_image_url=platform_data.get("profile_image_url"),
|
||||
is_active=account.is_active,
|
||||
connected_at=account.connected_at,
|
||||
platform_data=platform_data,
|
||||
)
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
social_account_service = SocialAccountService()
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
"""
|
||||
Social Uploader Module
|
||||
|
||||
소셜 미디어 영상 업로더 모듈입니다.
|
||||
"""
|
||||
|
||||
from app.social.constants import SocialPlatform
|
||||
from app.social.uploader.base import BaseSocialUploader, UploadResult
|
||||
|
||||
|
||||
def get_uploader(platform: SocialPlatform) -> BaseSocialUploader:
|
||||
"""
|
||||
플랫폼에 맞는 업로더 반환
|
||||
|
||||
Args:
|
||||
platform: 소셜 플랫폼
|
||||
|
||||
Returns:
|
||||
BaseSocialUploader: 업로더 인스턴스
|
||||
|
||||
Raises:
|
||||
ValueError: 지원하지 않는 플랫폼인 경우
|
||||
"""
|
||||
if platform == SocialPlatform.YOUTUBE:
|
||||
from app.social.uploader.youtube import YouTubeUploader
|
||||
|
||||
return YouTubeUploader()
|
||||
|
||||
# 추후 확장
|
||||
# elif platform == SocialPlatform.INSTAGRAM:
|
||||
# from app.social.uploader.instagram import InstagramUploader
|
||||
# return InstagramUploader()
|
||||
# elif platform == SocialPlatform.FACEBOOK:
|
||||
# from app.social.uploader.facebook import FacebookUploader
|
||||
# return FacebookUploader()
|
||||
# elif platform == SocialPlatform.TIKTOK:
|
||||
# from app.social.uploader.tiktok import TikTokUploader
|
||||
# return TikTokUploader()
|
||||
|
||||
raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseSocialUploader",
|
||||
"UploadResult",
|
||||
"get_uploader",
|
||||
]
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
"""
|
||||
Base Social Uploader
|
||||
|
||||
소셜 미디어 영상 업로더의 추상 기본 클래스입니다.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from app.social.constants import PrivacyStatus, SocialPlatform
|
||||
|
||||
|
||||
@dataclass
|
||||
class UploadMetadata:
|
||||
"""
|
||||
업로드 메타데이터
|
||||
|
||||
영상 업로드 시 필요한 메타데이터를 정의합니다.
|
||||
|
||||
Attributes:
|
||||
title: 영상 제목
|
||||
description: 영상 설명
|
||||
tags: 태그 목록
|
||||
privacy_status: 공개 상태
|
||||
platform_options: 플랫폼별 추가 옵션
|
||||
"""
|
||||
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
tags: Optional[list[str]] = None
|
||||
privacy_status: PrivacyStatus = PrivacyStatus.PRIVATE
|
||||
platform_options: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UploadResult:
|
||||
"""
|
||||
업로드 결과
|
||||
|
||||
Attributes:
|
||||
success: 성공 여부
|
||||
platform_video_id: 플랫폼에서 부여한 영상 ID
|
||||
platform_url: 플랫폼에서의 영상 URL
|
||||
error_message: 에러 메시지 (실패 시)
|
||||
platform_response: 플랫폼 원본 응답 (디버깅용)
|
||||
"""
|
||||
|
||||
success: bool
|
||||
platform_video_id: Optional[str] = None
|
||||
platform_url: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
platform_response: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class BaseSocialUploader(ABC):
|
||||
"""
|
||||
소셜 미디어 영상 업로더 추상 기본 클래스
|
||||
|
||||
모든 플랫폼별 업로더는 이 클래스를 상속받아 구현합니다.
|
||||
|
||||
Attributes:
|
||||
platform: 소셜 플랫폼 종류
|
||||
"""
|
||||
|
||||
platform: SocialPlatform
|
||||
|
||||
@abstractmethod
|
||||
async def upload(
|
||||
self,
|
||||
video_path: str,
|
||||
access_token: str,
|
||||
metadata: UploadMetadata,
|
||||
progress_callback: Optional[Callable[[int], None]] = None,
|
||||
) -> UploadResult:
|
||||
"""
|
||||
영상 업로드
|
||||
|
||||
Args:
|
||||
video_path: 업로드할 영상 파일 경로 (로컬 또는 URL)
|
||||
access_token: OAuth 액세스 토큰
|
||||
metadata: 업로드 메타데이터
|
||||
progress_callback: 진행률 콜백 함수 (0-100)
|
||||
|
||||
Returns:
|
||||
UploadResult: 업로드 결과
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_upload_status(
|
||||
self,
|
||||
platform_video_id: str,
|
||||
access_token: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
업로드 상태 조회
|
||||
|
||||
플랫폼에서 영상 처리 상태를 조회합니다.
|
||||
|
||||
Args:
|
||||
platform_video_id: 플랫폼 영상 ID
|
||||
access_token: OAuth 액세스 토큰
|
||||
|
||||
Returns:
|
||||
dict: 업로드 상태 정보
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete_video(
|
||||
self,
|
||||
platform_video_id: str,
|
||||
access_token: str,
|
||||
) -> bool:
|
||||
"""
|
||||
업로드된 영상 삭제
|
||||
|
||||
Args:
|
||||
platform_video_id: 플랫폼 영상 ID
|
||||
access_token: OAuth 액세스 토큰
|
||||
|
||||
Returns:
|
||||
bool: 삭제 성공 여부
|
||||
"""
|
||||
pass
|
||||
|
||||
def validate_metadata(self, metadata: UploadMetadata) -> None:
|
||||
"""
|
||||
메타데이터 유효성 검증
|
||||
|
||||
플랫폼별 제한사항을 확인합니다.
|
||||
|
||||
Args:
|
||||
metadata: 검증할 메타데이터
|
||||
|
||||
Raises:
|
||||
ValueError: 유효하지 않은 메타데이터
|
||||
"""
|
||||
if not metadata.title or len(metadata.title) == 0:
|
||||
raise ValueError("제목은 필수입니다.")
|
||||
|
||||
if len(metadata.title) > 100:
|
||||
raise ValueError("제목은 100자를 초과할 수 없습니다.")
|
||||
|
||||
if metadata.description and len(metadata.description) > 5000:
|
||||
raise ValueError("설명은 5000자를 초과할 수 없습니다.")
|
||||
|
||||
def get_video_url(self, platform_video_id: str) -> str:
|
||||
"""
|
||||
플랫폼 영상 URL 생성
|
||||
|
||||
Args:
|
||||
platform_video_id: 플랫폼 영상 ID
|
||||
|
||||
Returns:
|
||||
str: 영상 URL
|
||||
"""
|
||||
if self.platform == SocialPlatform.YOUTUBE:
|
||||
return f"https://www.youtube.com/watch?v={platform_video_id}"
|
||||
elif self.platform == SocialPlatform.INSTAGRAM:
|
||||
return f"https://www.instagram.com/reel/{platform_video_id}/"
|
||||
elif self.platform == SocialPlatform.FACEBOOK:
|
||||
return f"https://www.facebook.com/watch/?v={platform_video_id}"
|
||||
elif self.platform == SocialPlatform.TIKTOK:
|
||||
return f"https://www.tiktok.com/video/{platform_video_id}"
|
||||
else:
|
||||
return ""
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
"""
|
||||
YouTube Uploader
|
||||
|
||||
YouTube Data API v3를 사용한 영상 업로더입니다.
|
||||
Resumable Upload를 지원합니다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from config import social_upload_settings
|
||||
from app.social.constants import PrivacyStatus, SocialPlatform
|
||||
from app.social.exceptions import UploadError, UploadQuotaExceededError
|
||||
from app.social.uploader.base import BaseSocialUploader, UploadMetadata, UploadResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YouTubeUploader(BaseSocialUploader):
|
||||
"""
|
||||
YouTube 영상 업로더
|
||||
|
||||
YouTube Data API v3의 Resumable Upload를 사용하여
|
||||
대용량 영상을 안정적으로 업로드합니다.
|
||||
"""
|
||||
|
||||
platform = SocialPlatform.YOUTUBE
|
||||
|
||||
# YouTube API 엔드포인트
|
||||
UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos"
|
||||
VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"
|
||||
|
||||
# 청크 크기 (5MB - YouTube 권장)
|
||||
CHUNK_SIZE = 5 * 1024 * 1024
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.timeout = social_upload_settings.UPLOAD_TIMEOUT_SECONDS
|
||||
|
||||
async def upload(
|
||||
self,
|
||||
video_path: str,
|
||||
access_token: str,
|
||||
metadata: UploadMetadata,
|
||||
progress_callback: Optional[Callable[[int], None]] = None,
|
||||
) -> UploadResult:
|
||||
"""
|
||||
YouTube에 영상 업로드 (Resumable Upload)
|
||||
|
||||
Args:
|
||||
video_path: 업로드할 영상 파일 경로
|
||||
access_token: OAuth 액세스 토큰
|
||||
metadata: 업로드 메타데이터
|
||||
progress_callback: 진행률 콜백 함수 (0-100)
|
||||
|
||||
Returns:
|
||||
UploadResult: 업로드 결과
|
||||
"""
|
||||
logger.info(f"[YOUTUBE_UPLOAD] 업로드 시작 - video_path: {video_path}")
|
||||
|
||||
# 1. 메타데이터 유효성 검증
|
||||
self.validate_metadata(metadata)
|
||||
|
||||
# 2. 파일 크기 확인
|
||||
if not os.path.exists(video_path):
|
||||
logger.error(f"[YOUTUBE_UPLOAD] 파일 없음 - path: {video_path}")
|
||||
return UploadResult(
|
||||
success=False,
|
||||
error_message=f"파일을 찾을 수 없습니다: {video_path}",
|
||||
)
|
||||
|
||||
file_size = os.path.getsize(video_path)
|
||||
logger.info(f"[YOUTUBE_UPLOAD] 파일 크기: {file_size / (1024*1024):.2f} MB")
|
||||
|
||||
try:
|
||||
# 3. Resumable upload 세션 시작
|
||||
upload_url = await self._init_resumable_upload(
|
||||
access_token=access_token,
|
||||
metadata=metadata,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
# 4. 파일 업로드
|
||||
video_id = await self._upload_file(
|
||||
upload_url=upload_url,
|
||||
video_path=video_path,
|
||||
file_size=file_size,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
video_url = self.get_video_url(video_id)
|
||||
|
||||
logger.info(
|
||||
f"[YOUTUBE_UPLOAD] 업로드 성공 - video_id: {video_id}, url: {video_url}"
|
||||
)
|
||||
|
||||
return UploadResult(
|
||||
success=True,
|
||||
platform_video_id=video_id,
|
||||
platform_url=video_url,
|
||||
)
|
||||
|
||||
except UploadQuotaExceededError:
|
||||
raise
|
||||
except UploadError as e:
|
||||
logger.error(f"[YOUTUBE_UPLOAD] 업로드 실패 - error: {e}")
|
||||
return UploadResult(
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[YOUTUBE_UPLOAD] 예상치 못한 에러 - error: {e}")
|
||||
return UploadResult(
|
||||
success=False,
|
||||
error_message=f"업로드 중 에러 발생: {str(e)}",
|
||||
)
|
||||
|
||||
async def _init_resumable_upload(
|
||||
self,
|
||||
access_token: str,
|
||||
metadata: UploadMetadata,
|
||||
file_size: int,
|
||||
) -> str:
|
||||
"""
|
||||
Resumable upload 세션 시작
|
||||
|
||||
Args:
|
||||
access_token: OAuth 액세스 토큰
|
||||
metadata: 업로드 메타데이터
|
||||
file_size: 파일 크기
|
||||
|
||||
Returns:
|
||||
str: 업로드 URL
|
||||
|
||||
Raises:
|
||||
UploadError: 세션 시작 실패
|
||||
"""
|
||||
logger.debug("[YOUTUBE_UPLOAD] Resumable upload 세션 시작")
|
||||
|
||||
# YouTube API 요청 본문
|
||||
body = {
|
||||
"snippet": {
|
||||
"title": metadata.title,
|
||||
"description": metadata.description or "",
|
||||
"tags": metadata.tags or [],
|
||||
"categoryId": self._get_category_id(metadata),
|
||||
},
|
||||
"status": {
|
||||
"privacyStatus": self._convert_privacy_status(metadata.privacy_status),
|
||||
"selfDeclaredMadeForKids": False,
|
||||
},
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"X-Upload-Content-Type": "video/*",
|
||||
"X-Upload-Content-Length": str(file_size),
|
||||
}
|
||||
|
||||
params = {
|
||||
"uploadType": "resumable",
|
||||
"part": "snippet,status",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
self.UPLOAD_URL,
|
||||
params=params,
|
||||
headers=headers,
|
||||
json=body,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
upload_url = response.headers.get("location")
|
||||
if upload_url:
|
||||
logger.debug(
|
||||
f"[YOUTUBE_UPLOAD] 세션 시작 성공 - upload_url: {upload_url[:50]}..."
|
||||
)
|
||||
return upload_url
|
||||
|
||||
# 에러 처리
|
||||
error_data = response.json() if response.content else {}
|
||||
error_reason = (
|
||||
error_data.get("error", {}).get("errors", [{}])[0].get("reason", "")
|
||||
)
|
||||
|
||||
if error_reason == "quotaExceeded":
|
||||
logger.error("[YOUTUBE_UPLOAD] API 할당량 초과")
|
||||
raise UploadQuotaExceededError(platform=self.platform.value)
|
||||
|
||||
error_message = error_data.get("error", {}).get(
|
||||
"message", f"HTTP {response.status_code}"
|
||||
)
|
||||
logger.error(f"[YOUTUBE_UPLOAD] 세션 시작 실패 - error: {error_message}")
|
||||
raise UploadError(
|
||||
platform=self.platform.value,
|
||||
detail=f"Resumable upload 세션 시작 실패: {error_message}",
|
||||
)
|
||||
|
||||
async def _upload_file(
|
||||
self,
|
||||
upload_url: str,
|
||||
video_path: str,
|
||||
file_size: int,
|
||||
progress_callback: Optional[Callable[[int], None]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
파일 청크 업로드
|
||||
|
||||
Args:
|
||||
upload_url: Resumable upload URL
|
||||
video_path: 영상 파일 경로
|
||||
file_size: 파일 크기
|
||||
progress_callback: 진행률 콜백
|
||||
|
||||
Returns:
|
||||
str: YouTube 영상 ID
|
||||
|
||||
Raises:
|
||||
UploadError: 업로드 실패
|
||||
"""
|
||||
uploaded_bytes = 0
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
with open(video_path, "rb") as video_file:
|
||||
while uploaded_bytes < file_size:
|
||||
# 청크 읽기
|
||||
chunk = video_file.read(self.CHUNK_SIZE)
|
||||
chunk_size = len(chunk)
|
||||
end_byte = uploaded_bytes + chunk_size - 1
|
||||
|
||||
headers = {
|
||||
"Content-Type": "video/*",
|
||||
"Content-Length": str(chunk_size),
|
||||
"Content-Range": f"bytes {uploaded_bytes}-{end_byte}/{file_size}",
|
||||
}
|
||||
|
||||
response = await client.put(
|
||||
upload_url,
|
||||
headers=headers,
|
||||
content=chunk,
|
||||
)
|
||||
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
# 업로드 완료
|
||||
result = response.json()
|
||||
video_id = result.get("id")
|
||||
if video_id:
|
||||
return video_id
|
||||
raise UploadError(
|
||||
platform=self.platform.value,
|
||||
detail="응답에서 video ID를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
elif response.status_code == 308:
|
||||
# 청크 업로드 성공, 계속 진행
|
||||
uploaded_bytes += chunk_size
|
||||
progress = int((uploaded_bytes / file_size) * 100)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(progress)
|
||||
|
||||
logger.debug(
|
||||
f"[YOUTUBE_UPLOAD] 청크 업로드 완료 - "
|
||||
f"progress: {progress}%, "
|
||||
f"uploaded: {uploaded_bytes}/{file_size}"
|
||||
)
|
||||
|
||||
else:
|
||||
# 에러
|
||||
error_data = response.json() if response.content else {}
|
||||
error_message = error_data.get("error", {}).get(
|
||||
"message", f"HTTP {response.status_code}"
|
||||
)
|
||||
logger.error(
|
||||
f"[YOUTUBE_UPLOAD] 청크 업로드 실패 - error: {error_message}"
|
||||
)
|
||||
raise UploadError(
|
||||
platform=self.platform.value,
|
||||
detail=f"청크 업로드 실패: {error_message}",
|
||||
)
|
||||
|
||||
raise UploadError(
|
||||
platform=self.platform.value,
|
||||
detail="업로드가 완료되지 않았습니다.",
|
||||
)
|
||||
|
||||
async def get_upload_status(
|
||||
self,
|
||||
platform_video_id: str,
|
||||
access_token: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
업로드 상태 조회
|
||||
|
||||
Args:
|
||||
platform_video_id: YouTube 영상 ID
|
||||
access_token: OAuth 액세스 토큰
|
||||
|
||||
Returns:
|
||||
dict: 업로드 상태 정보
|
||||
"""
|
||||
logger.info(f"[YOUTUBE_UPLOAD] 상태 조회 - video_id: {platform_video_id}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
params = {
|
||||
"part": "status,processingDetails",
|
||||
"id": platform_video_id,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
self.VIDEOS_URL,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
items = data.get("items", [])
|
||||
|
||||
if items:
|
||||
item = items[0]
|
||||
status = item.get("status", {})
|
||||
processing = item.get("processingDetails", {})
|
||||
|
||||
return {
|
||||
"upload_status": status.get("uploadStatus"),
|
||||
"privacy_status": status.get("privacyStatus"),
|
||||
"processing_status": processing.get(
|
||||
"processingStatus", "processing"
|
||||
),
|
||||
"processing_progress": processing.get(
|
||||
"processingProgress", {}
|
||||
),
|
||||
}
|
||||
|
||||
return {"error": "영상을 찾을 수 없습니다."}
|
||||
|
||||
return {"error": f"상태 조회 실패: HTTP {response.status_code}"}
|
||||
|
||||
async def delete_video(
|
||||
self,
|
||||
platform_video_id: str,
|
||||
access_token: str,
|
||||
) -> bool:
|
||||
"""
|
||||
업로드된 영상 삭제
|
||||
|
||||
Args:
|
||||
platform_video_id: YouTube 영상 ID
|
||||
access_token: OAuth 액세스 토큰
|
||||
|
||||
Returns:
|
||||
bool: 삭제 성공 여부
|
||||
"""
|
||||
logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 - video_id: {platform_video_id}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
params = {"id": platform_video_id}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.delete(
|
||||
self.VIDEOS_URL,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
if response.status_code == 204:
|
||||
logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 성공 - video_id: {platform_video_id}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"[YOUTUBE_UPLOAD] 영상 삭제 실패 - "
|
||||
f"video_id: {platform_video_id}, status: {response.status_code}"
|
||||
)
|
||||
return False
|
||||
|
||||
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
|
||||
"""
|
||||
PrivacyStatus를 YouTube API 형식으로 변환
|
||||
|
||||
Args:
|
||||
privacy_status: 공개 상태
|
||||
|
||||
Returns:
|
||||
str: YouTube API 공개 상태
|
||||
"""
|
||||
mapping = {
|
||||
PrivacyStatus.PUBLIC: "public",
|
||||
PrivacyStatus.UNLISTED: "unlisted",
|
||||
PrivacyStatus.PRIVATE: "private",
|
||||
}
|
||||
return mapping.get(privacy_status, "private")
|
||||
|
||||
def _get_category_id(self, metadata: UploadMetadata) -> str:
|
||||
"""
|
||||
카테고리 ID 추출
|
||||
|
||||
platform_options에서 category_id를 추출하거나 기본값 반환
|
||||
|
||||
Args:
|
||||
metadata: 업로드 메타데이터
|
||||
|
||||
Returns:
|
||||
str: YouTube 카테고리 ID
|
||||
"""
|
||||
if metadata.platform_options and "category_id" in metadata.platform_options:
|
||||
return str(metadata.platform_options["category_id"])
|
||||
|
||||
# 기본값: "22" (People & Blogs)
|
||||
return "22"
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
youtube_uploader = YouTubeUploader()
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
"""
|
||||
Social Worker Module
|
||||
|
||||
소셜 미디어 백그라운드 태스크 모듈입니다.
|
||||
"""
|
||||
|
||||
from app.social.worker.upload_task import process_social_upload
|
||||
|
||||
__all__ = ["process_social_upload"]
|
||||
|
|
@ -1,374 +0,0 @@
|
|||
"""
|
||||
Social Upload Background Task
|
||||
|
||||
소셜 미디어 영상 업로드 백그라운드 태스크입니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from config import social_upload_settings
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.social.constants import SocialPlatform, UploadStatus
|
||||
from app.social.exceptions import UploadError, UploadQuotaExceededError
|
||||
from app.social.models import SocialUpload
|
||||
from app.social.services import social_account_service
|
||||
from app.social.uploader import get_uploader
|
||||
from app.social.uploader.base import UploadMetadata
|
||||
from app.user.models import SocialAccount
|
||||
from app.video.models import Video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _update_upload_status(
|
||||
upload_id: int,
|
||||
status: UploadStatus,
|
||||
upload_progress: int = 0,
|
||||
platform_video_id: Optional[str] = None,
|
||||
platform_url: Optional[str] = None,
|
||||
error_message: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
업로드 상태 업데이트
|
||||
|
||||
Args:
|
||||
upload_id: SocialUpload ID
|
||||
status: 업로드 상태
|
||||
upload_progress: 업로드 진행률 (0-100)
|
||||
platform_video_id: 플랫폼 영상 ID
|
||||
platform_url: 플랫폼 영상 URL
|
||||
error_message: 에러 메시지
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(SocialUpload.id == upload_id)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if upload:
|
||||
upload.status = status.value
|
||||
upload.upload_progress = upload_progress
|
||||
|
||||
if platform_video_id:
|
||||
upload.platform_video_id = platform_video_id
|
||||
if platform_url:
|
||||
upload.platform_url = platform_url
|
||||
if error_message:
|
||||
upload.error_message = error_message
|
||||
if status == UploadStatus.COMPLETED:
|
||||
upload.uploaded_at = datetime.now()
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"[SOCIAL_UPLOAD] 상태 업데이트 - "
|
||||
f"upload_id: {upload_id}, status: {status.value}, progress: {upload_progress}%"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[SOCIAL_UPLOAD] DB 에러 - upload_id: {upload_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _download_video(video_url: str, upload_id: int) -> bytes:
|
||||
"""
|
||||
영상 파일 다운로드
|
||||
|
||||
Args:
|
||||
video_url: 영상 URL
|
||||
upload_id: 업로드 ID (로그용)
|
||||
|
||||
Returns:
|
||||
bytes: 영상 파일 내용
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: 다운로드 실패
|
||||
"""
|
||||
logger.info(f"[SOCIAL_UPLOAD] 영상 다운로드 시작 - upload_id: {upload_id}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
response = await client.get(video_url)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info(
|
||||
f"[SOCIAL_UPLOAD] 영상 다운로드 완료 - "
|
||||
f"upload_id: {upload_id}, size: {len(response.content)} bytes"
|
||||
)
|
||||
return response.content
|
||||
|
||||
|
||||
async def _increment_retry_count(upload_id: int) -> int:
|
||||
"""
|
||||
재시도 횟수 증가
|
||||
|
||||
Args:
|
||||
upload_id: SocialUpload ID
|
||||
|
||||
Returns:
|
||||
int: 현재 재시도 횟수
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(SocialUpload.id == upload_id)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if upload:
|
||||
upload.retry_count += 1
|
||||
await session.commit()
|
||||
return upload.retry_count
|
||||
|
||||
return 0
|
||||
|
||||
except SQLAlchemyError:
|
||||
return 0
|
||||
|
||||
|
||||
async def process_social_upload(upload_id: int) -> None:
|
||||
"""
|
||||
소셜 미디어 업로드 처리
|
||||
|
||||
백그라운드에서 실행되며, 영상을 소셜 플랫폼에 업로드합니다.
|
||||
|
||||
Args:
|
||||
upload_id: SocialUpload ID
|
||||
"""
|
||||
logger.info(f"[SOCIAL_UPLOAD] 업로드 태스크 시작 - upload_id: {upload_id}")
|
||||
|
||||
temp_file_path: Optional[Path] = None
|
||||
|
||||
try:
|
||||
# 1. 업로드 정보 조회
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(SocialUpload.id == upload_id)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if not upload:
|
||||
logger.error(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}")
|
||||
return
|
||||
|
||||
# 2. Video 정보 조회
|
||||
video_result = await session.execute(
|
||||
select(Video).where(Video.id == upload.video_id)
|
||||
)
|
||||
video = video_result.scalar_one_or_none()
|
||||
|
||||
if not video or not video.result_movie_url:
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 영상 없음 또는 URL 없음 - "
|
||||
f"upload_id: {upload_id}, video_id: {upload.video_id}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message="영상을 찾을 수 없거나 URL이 없습니다.",
|
||||
)
|
||||
return
|
||||
|
||||
# 3. SocialAccount 정보 조회
|
||||
account_result = await session.execute(
|
||||
select(SocialAccount).where(SocialAccount.id == upload.social_account_id)
|
||||
)
|
||||
account = account_result.scalar_one_or_none()
|
||||
|
||||
if not account or not account.is_active:
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 소셜 계정 없음 또는 비활성화 - "
|
||||
f"upload_id: {upload_id}, account_id: {upload.social_account_id}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message="연동된 소셜 계정이 없거나 비활성화 상태입니다.",
|
||||
)
|
||||
return
|
||||
|
||||
# 필요한 정보 저장
|
||||
video_url = video.result_movie_url
|
||||
platform = SocialPlatform(upload.platform)
|
||||
upload_title = upload.title
|
||||
upload_description = upload.description
|
||||
upload_tags = upload.tags if isinstance(upload.tags, list) else None
|
||||
upload_privacy = upload.privacy_status
|
||||
upload_options = upload.platform_options
|
||||
|
||||
# 4. 상태 업데이트: uploading
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.UPLOADING,
|
||||
upload_progress=0,
|
||||
)
|
||||
|
||||
# 5. 토큰 유효성 확인 및 갱신
|
||||
async with BackgroundSessionLocal() as session:
|
||||
# account 다시 조회 (세션이 닫혔으므로)
|
||||
account_result = await session.execute(
|
||||
select(SocialAccount).where(SocialAccount.id == upload.social_account_id)
|
||||
)
|
||||
account = account_result.scalar_one_or_none()
|
||||
|
||||
if not account:
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message="소셜 계정을 찾을 수 없습니다.",
|
||||
)
|
||||
return
|
||||
|
||||
access_token = await social_account_service.ensure_valid_token(
|
||||
account=account,
|
||||
session=session,
|
||||
)
|
||||
|
||||
# 6. 영상 다운로드
|
||||
video_content = await _download_video(video_url, upload_id)
|
||||
|
||||
# 7. 임시 파일 저장
|
||||
temp_dir = Path(social_upload_settings.UPLOAD_TEMP_DIR) / str(upload_id)
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file_path = temp_dir / "video.mp4"
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(video_content)
|
||||
|
||||
logger.info(
|
||||
f"[SOCIAL_UPLOAD] 임시 파일 저장 완료 - "
|
||||
f"upload_id: {upload_id}, path: {temp_file_path}"
|
||||
)
|
||||
|
||||
# 8. 메타데이터 준비
|
||||
from app.social.constants import PrivacyStatus
|
||||
|
||||
metadata = UploadMetadata(
|
||||
title=upload_title,
|
||||
description=upload_description,
|
||||
tags=upload_tags,
|
||||
privacy_status=PrivacyStatus(upload_privacy),
|
||||
platform_options=upload_options,
|
||||
)
|
||||
|
||||
# 9. 진행률 콜백 함수
|
||||
async def progress_callback(progress: int) -> None:
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.UPLOADING,
|
||||
upload_progress=progress,
|
||||
)
|
||||
|
||||
# 10. 플랫폼에 업로드
|
||||
uploader = get_uploader(platform)
|
||||
|
||||
# 동기 콜백으로 변환 (httpx 청크 업로드 내에서 호출되므로)
|
||||
def sync_progress_callback(progress: int) -> None:
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.create_task(
|
||||
_update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.UPLOADING,
|
||||
upload_progress=progress,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = await uploader.upload(
|
||||
video_path=str(temp_file_path),
|
||||
access_token=access_token,
|
||||
metadata=metadata,
|
||||
progress_callback=sync_progress_callback,
|
||||
)
|
||||
|
||||
# 11. 결과 처리
|
||||
if result.success:
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.COMPLETED,
|
||||
upload_progress=100,
|
||||
platform_video_id=result.platform_video_id,
|
||||
platform_url=result.platform_url,
|
||||
)
|
||||
logger.info(
|
||||
f"[SOCIAL_UPLOAD] 업로드 완료 - "
|
||||
f"upload_id: {upload_id}, "
|
||||
f"platform_video_id: {result.platform_video_id}, "
|
||||
f"url: {result.platform_url}"
|
||||
)
|
||||
else:
|
||||
retry_count = await _increment_retry_count(upload_id)
|
||||
|
||||
if retry_count < social_upload_settings.UPLOAD_MAX_RETRIES:
|
||||
# 재시도 가능
|
||||
logger.warning(
|
||||
f"[SOCIAL_UPLOAD] 업로드 실패, 재시도 예정 - "
|
||||
f"upload_id: {upload_id}, retry: {retry_count}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.PENDING,
|
||||
upload_progress=0,
|
||||
error_message=f"업로드 실패 (재시도 {retry_count}/{social_upload_settings.UPLOAD_MAX_RETRIES}): {result.error_message}",
|
||||
)
|
||||
else:
|
||||
# 최대 재시도 초과
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message=f"최대 재시도 횟수 초과: {result.error_message}",
|
||||
)
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 업로드 최종 실패 - "
|
||||
f"upload_id: {upload_id}, error: {result.error_message}"
|
||||
)
|
||||
|
||||
except UploadQuotaExceededError as e:
|
||||
logger.error(f"[SOCIAL_UPLOAD] API 할당량 초과 - upload_id: {upload_id}")
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 예상치 못한 에러 - "
|
||||
f"upload_id: {upload_id}, error: {e}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message=f"업로드 중 에러 발생: {str(e)}",
|
||||
)
|
||||
|
||||
finally:
|
||||
# 임시 파일 정리
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
temp_file_path.parent.rmdir()
|
||||
logger.debug(f"[SOCIAL_UPLOAD] 임시 파일 삭제 - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[SOCIAL_UPLOAD] 임시 파일 삭제 실패 - error: {e}")
|
||||
|
|
@ -33,7 +33,6 @@ async def _update_song_status(
|
|||
song_url: str | None = None,
|
||||
suno_task_id: str | None = None,
|
||||
duration: float | None = None,
|
||||
song_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Song 테이블의 상태를 업데이트합니다.
|
||||
|
||||
|
|
@ -43,20 +42,13 @@ async def _update_song_status(
|
|||
song_url: 노래 URL
|
||||
suno_task_id: Suno task ID (선택)
|
||||
duration: 노래 길이 (선택)
|
||||
song_id: 특정 Song 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
if song_id:
|
||||
# song_id로 특정 레코드 조회 (가장 정확한 식별)
|
||||
query_result = await session.execute(
|
||||
select(Song).where(Song.id == song_id)
|
||||
)
|
||||
elif suno_task_id:
|
||||
# suno_task_id로 조회 (Suno API 고유 ID)
|
||||
if suno_task_id:
|
||||
query_result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
|
|
@ -64,7 +56,6 @@ async def _update_song_status(
|
|||
.limit(1)
|
||||
)
|
||||
else:
|
||||
# 기존 방식: task_id로 최신 레코드 조회 (비권장)
|
||||
query_result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
|
|
@ -81,17 +72,17 @@ async def _update_song_status(
|
|||
if duration is not None:
|
||||
song.duration = duration
|
||||
await session.commit()
|
||||
logger.info(f"[Song] Status updated - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, status: {status}")
|
||||
logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}")
|
||||
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, error: {e}")
|
||||
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, error: {e}")
|
||||
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -568,7 +568,7 @@ async def get_video_status(
|
|||
|
||||
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
|
||||
logger.info(
|
||||
f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}, creatomate_render_id: {creatomate_render_id}"
|
||||
f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}"
|
||||
)
|
||||
background_tasks.add_task(
|
||||
download_and_upload_video_to_blob,
|
||||
|
|
@ -576,7 +576,6 @@ async def get_video_status(
|
|||
video_url=video_url,
|
||||
store_name=store_name,
|
||||
user_uuid=current_user.user_uuid,
|
||||
creatomate_render_id=creatomate_render_id,
|
||||
)
|
||||
elif video and video.status == "completed":
|
||||
logger.debug(
|
||||
|
|
@ -831,7 +830,6 @@ async def get_videos(
|
|||
project = projects_map.get(video.project_id)
|
||||
|
||||
item = VideoListItem(
|
||||
video_id=video.id,
|
||||
store_name=project.store_name if project else None,
|
||||
region=project.region if project else None,
|
||||
task_id=video.task_id,
|
||||
|
|
@ -859,5 +857,3 @@ async def get_videos(
|
|||
status_code=500,
|
||||
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Video API Schemas
|
|||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
|
@ -141,7 +141,6 @@ class VideoListItem(BaseModel):
|
|||
|
||||
Example:
|
||||
{
|
||||
"video_id": 1,
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
|
|
@ -150,11 +149,8 @@ class VideoListItem(BaseModel):
|
|||
}
|
||||
"""
|
||||
|
||||
video_id: int = Field(..., description="영상 고유 ID")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,6 @@ async def download_and_upload_video_to_blob(
|
|||
video_url: str,
|
||||
store_name: str,
|
||||
user_uuid: str,
|
||||
creatomate_render_id: str | None = None,
|
||||
) -> None:
|
||||
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||
|
||||
|
|
@ -116,7 +115,6 @@ async def download_and_upload_video_to_blob(
|
|||
video_url: 다운로드할 영상 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
||||
creatomate_render_id: Creatomate 렌더 ID (특정 Video 식별용)
|
||||
"""
|
||||
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
||||
temp_file_path: Path | None = None
|
||||
|
|
@ -156,21 +154,21 @@ async def download_and_upload_video_to_blob(
|
|||
blob_url = uploader.public_url
|
||||
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||
|
||||
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
|
||||
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
|
||||
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}")
|
||||
# Video 테이블 업데이트
|
||||
await _update_video_status(task_id, "completed", blob_url)
|
||||
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
await _update_video_status(task_id, "failed")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
await _update_video_status(task_id, "failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
await _update_video_status(task_id, "failed")
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
|
|
|
|||
134
config.py
134
config.py
|
|
@ -452,138 +452,6 @@ class LogSettings(BaseSettings):
|
|||
return default_log_dir
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Social Media OAuth Settings
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SocialOAuthSettings(BaseSettings):
|
||||
"""소셜 미디어 OAuth 설정
|
||||
|
||||
YouTube, Instagram, Facebook 등 소셜 미디어 플랫폼 OAuth 인증 설정입니다.
|
||||
각 플랫폼의 개발자 콘솔에서 앱을 생성하고 클라이언트 ID/Secret을 발급받아야 합니다.
|
||||
|
||||
YouTube (Google Cloud Console):
|
||||
- https://console.cloud.google.com/
|
||||
- YouTube Data API v3 활성화 필요
|
||||
- OAuth 동의 화면 설정 필요
|
||||
|
||||
Instagram/Facebook (Meta for Developers):
|
||||
- https://developers.facebook.com/
|
||||
- Instagram Graph API 사용을 위해 Facebook 앱 필요
|
||||
- 비즈니스/크리에이터 계정 필요
|
||||
|
||||
TikTok (TikTok for Developers):
|
||||
- https://developers.tiktok.com/
|
||||
- Content Posting API 권한 필요
|
||||
"""
|
||||
|
||||
# ============================================================
|
||||
# YouTube (Google OAuth)
|
||||
# ============================================================
|
||||
YOUTUBE_CLIENT_ID: str = Field(
|
||||
default="",
|
||||
description="Google OAuth 클라이언트 ID (Google Cloud Console에서 발급)",
|
||||
)
|
||||
YOUTUBE_CLIENT_SECRET: str = Field(
|
||||
default="",
|
||||
description="Google OAuth 클라이언트 Secret",
|
||||
)
|
||||
YOUTUBE_REDIRECT_URI: str = Field(
|
||||
default="http://localhost:8000/social/oauth/youtube/callback",
|
||||
description="YouTube OAuth 콜백 URI",
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Instagram (Meta/Facebook OAuth) - 추후 구현
|
||||
# ============================================================
|
||||
# INSTAGRAM_APP_ID: str = Field(default="", description="Facebook App ID")
|
||||
# INSTAGRAM_APP_SECRET: str = Field(default="", description="Facebook App Secret")
|
||||
# INSTAGRAM_REDIRECT_URI: str = Field(
|
||||
# default="http://localhost:8000/social/instagram/callback",
|
||||
# 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_CLIENT_KEY: str = Field(default="", description="TikTok Client Key")
|
||||
# TIKTOK_CLIENT_SECRET: str = Field(default="", description="TikTok Client Secret")
|
||||
# TIKTOK_REDIRECT_URI: str = Field(
|
||||
# default="http://localhost:8000/social/tiktok/callback",
|
||||
# description="TikTok OAuth 콜백 URI",
|
||||
# )
|
||||
|
||||
# ============================================================
|
||||
# 공통 설정
|
||||
# ============================================================
|
||||
OAUTH_STATE_TTL_SECONDS: int = Field(
|
||||
default=300,
|
||||
description="OAuth state 토큰 유효 시간 (초). CSRF 방지용 state는 Redis에 저장됨",
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 프론트엔드 리다이렉트 설정
|
||||
# ============================================================
|
||||
OAUTH_FRONTEND_URL: str = Field(
|
||||
default="http://localhost:3000",
|
||||
description="OAuth 완료 후 리다이렉트할 프론트엔드 URL (프로토콜 포함)",
|
||||
)
|
||||
OAUTH_SUCCESS_PATH: str = Field(
|
||||
default="/social/connect/success",
|
||||
description="OAuth 성공 시 리다이렉트 경로",
|
||||
)
|
||||
OAUTH_ERROR_PATH: str = Field(
|
||||
default="/social/connect/error",
|
||||
description="OAuth 실패/취소 시 리다이렉트 경로",
|
||||
)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class SocialUploadSettings(BaseSettings):
|
||||
"""소셜 미디어 업로드 설정
|
||||
|
||||
영상 업로드 관련 타임아웃, 재시도, 임시 파일 관리 설정입니다.
|
||||
"""
|
||||
|
||||
# ============================================================
|
||||
# 업로드 설정
|
||||
# ============================================================
|
||||
UPLOAD_MAX_RETRIES: int = Field(
|
||||
default=3,
|
||||
description="업로드 실패 시 최대 재시도 횟수",
|
||||
)
|
||||
UPLOAD_RETRY_DELAY_SECONDS: int = Field(
|
||||
default=10,
|
||||
description="재시도 전 대기 시간 (초)",
|
||||
)
|
||||
UPLOAD_TIMEOUT_SECONDS: float = Field(
|
||||
default=600.0,
|
||||
description="업로드 타임아웃 (초). 대용량 영상 업로드를 위해 충분한 시간 설정",
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 임시 파일 설정
|
||||
# ============================================================
|
||||
UPLOAD_TEMP_DIR: str = Field(
|
||||
default="media/temp/social",
|
||||
description="업로드 임시 파일 저장 디렉토리",
|
||||
)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
prj_settings = ProjectSettings()
|
||||
apikey_settings = APIKeySettings()
|
||||
db_settings = DatabaseSettings()
|
||||
|
|
@ -597,5 +465,3 @@ log_settings = LogSettings()
|
|||
kakao_settings = KakaoSettings()
|
||||
jwt_settings = JWTSettings()
|
||||
recovery_settings = RecoverySettings()
|
||||
social_oauth_settings = SocialOAuthSettings()
|
||||
social_upload_settings = SocialUploadSettings()
|
||||
|
|
|
|||
|
|
@ -1,601 +0,0 @@
|
|||
{
|
||||
"info": {
|
||||
"title": "Social Media Integration API",
|
||||
"version": "1.0.0",
|
||||
"description": "소셜 미디어 연동 및 영상 업로드 API 명세서",
|
||||
"baseUrl": "http://localhost:8000"
|
||||
},
|
||||
"authentication": {
|
||||
"type": "Bearer Token",
|
||||
"header": "Authorization",
|
||||
"format": "Bearer {access_token}",
|
||||
"description": "카카오 로그인 후 발급받은 JWT access_token 사용"
|
||||
},
|
||||
"endpoints": {
|
||||
"oauth": {
|
||||
"connect": {
|
||||
"name": "소셜 계정 연동 시작",
|
||||
"method": "GET",
|
||||
"url": "/social/oauth/{platform}/connect",
|
||||
"description": "OAuth 인증 URL을 생성합니다. 반환된 auth_url로 사용자를 리다이렉트하세요.",
|
||||
"authentication": true,
|
||||
"pathParameters": {
|
||||
"platform": {
|
||||
"type": "string",
|
||||
"enum": ["youtube"],
|
||||
"description": "연동할 플랫폼 (현재 youtube만 지원)"
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"success": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=xxx&redirect_uri=xxx&response_type=code&scope=xxx&state=xxx",
|
||||
"state": "abc123xyz789",
|
||||
"platform": "youtube"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"401": {
|
||||
"detail": "인증이 필요합니다."
|
||||
},
|
||||
"422": {
|
||||
"detail": "지원하지 않는 플랫폼입니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"frontendAction": "auth_url로 window.location.href 또는 새 창으로 리다이렉트"
|
||||
},
|
||||
"callback": {
|
||||
"name": "OAuth 콜백 (백엔드 자동 처리)",
|
||||
"method": "GET",
|
||||
"url": "/social/oauth/{platform}/callback",
|
||||
"description": "Google에서 자동으로 호출됩니다. 프론트엔드에서 직접 호출하지 마세요.",
|
||||
"authentication": false,
|
||||
"note": "연동 성공 시 프론트엔드의 /social/connect/success 페이지로 리다이렉트됩니다.",
|
||||
"redirectOnSuccess": {
|
||||
"url": "{PROJECT_DOMAIN}/social/connect/success",
|
||||
"queryParams": {
|
||||
"platform": "youtube",
|
||||
"account_id": 1,
|
||||
"channel_name": "My YouTube Channel",
|
||||
"profile_image": "https://yt3.ggpht.com/..."
|
||||
},
|
||||
"example": "/social/connect/success?platform=youtube&account_id=1&channel_name=My+YouTube+Channel&profile_image=https%3A%2F%2Fyt3.ggpht.com%2F..."
|
||||
},
|
||||
"redirectOnError": {
|
||||
"url": "{PROJECT_DOMAIN}/social/connect/error",
|
||||
"queryParams": {
|
||||
"platform": "youtube",
|
||||
"error": "에러 메시지"
|
||||
}
|
||||
}
|
||||
},
|
||||
"getAccounts": {
|
||||
"name": "연동된 계정 목록 조회",
|
||||
"method": "GET",
|
||||
"url": "/social/oauth/accounts",
|
||||
"description": "현재 사용자가 연동한 모든 소셜 계정 목록을 반환합니다.",
|
||||
"authentication": true,
|
||||
"request": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"success": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"accounts": [
|
||||
{
|
||||
"id": 1,
|
||||
"platform": "youtube",
|
||||
"platform_user_id": "UC1234567890abcdef",
|
||||
"platform_username": "@mychannel",
|
||||
"display_name": "My YouTube Channel",
|
||||
"profile_image_url": "https://yt3.ggpht.com/...",
|
||||
"is_active": true,
|
||||
"connected_at": "2024-01-15T12:00:00",
|
||||
"platform_data": {
|
||||
"channel_id": "UC1234567890abcdef",
|
||||
"channel_title": "My YouTube Channel",
|
||||
"subscriber_count": "1000",
|
||||
"video_count": "50"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"getAccountByPlatform": {
|
||||
"name": "특정 플랫폼 연동 계정 조회",
|
||||
"method": "GET",
|
||||
"url": "/social/oauth/accounts/{platform}",
|
||||
"description": "특정 플랫폼에 연동된 계정 정보를 반환합니다.",
|
||||
"authentication": true,
|
||||
"pathParameters": {
|
||||
"platform": {
|
||||
"type": "string",
|
||||
"enum": ["youtube"],
|
||||
"description": "조회할 플랫폼"
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"success": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"id": 1,
|
||||
"platform": "youtube",
|
||||
"platform_user_id": "UC1234567890abcdef",
|
||||
"platform_username": "@mychannel",
|
||||
"display_name": "My YouTube Channel",
|
||||
"profile_image_url": "https://yt3.ggpht.com/...",
|
||||
"is_active": true,
|
||||
"connected_at": "2024-01-15T12:00:00",
|
||||
"platform_data": {
|
||||
"channel_id": "UC1234567890abcdef",
|
||||
"channel_title": "My YouTube Channel",
|
||||
"subscriber_count": "1000",
|
||||
"video_count": "50"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"404": {
|
||||
"detail": "youtube 플랫폼에 연동된 계정이 없습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"disconnect": {
|
||||
"name": "소셜 계정 연동 해제",
|
||||
"method": "DELETE",
|
||||
"url": "/social/oauth/{platform}/disconnect",
|
||||
"description": "소셜 미디어 계정 연동을 해제합니다.",
|
||||
"authentication": true,
|
||||
"pathParameters": {
|
||||
"platform": {
|
||||
"type": "string",
|
||||
"enum": ["youtube"],
|
||||
"description": "연동 해제할 플랫폼"
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"success": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"success": true,
|
||||
"message": "youtube 계정 연동이 해제되었습니다."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"404": {
|
||||
"detail": "youtube 플랫폼에 연동된 계정이 없습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"create": {
|
||||
"name": "소셜 플랫폼에 영상 업로드 요청",
|
||||
"method": "POST",
|
||||
"url": "/social/upload",
|
||||
"description": "영상을 소셜 미디어 플랫폼에 업로드합니다. 백그라운드에서 처리됩니다.",
|
||||
"authentication": true,
|
||||
"request": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"video_id": {
|
||||
"type": "integer",
|
||||
"required": true,
|
||||
"description": "업로드할 영상 ID (Video 테이블의 id)",
|
||||
"example": 123
|
||||
},
|
||||
"platform": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"enum": ["youtube"],
|
||||
"description": "업로드할 플랫폼",
|
||||
"example": "youtube"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 100,
|
||||
"description": "영상 제목",
|
||||
"example": "나의 첫 영상"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"maxLength": 5000,
|
||||
"description": "영상 설명",
|
||||
"example": "이 영상은 테스트 영상입니다."
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"items": "string",
|
||||
"description": "태그 목록",
|
||||
"example": ["여행", "vlog", "일상"]
|
||||
},
|
||||
"privacy_status": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"enum": ["public", "unlisted", "private"],
|
||||
"default": "private",
|
||||
"description": "공개 상태",
|
||||
"example": "private"
|
||||
},
|
||||
"platform_options": {
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"description": "플랫폼별 추가 옵션",
|
||||
"example": {
|
||||
"category_id": "22"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"video_id": 123,
|
||||
"platform": "youtube",
|
||||
"title": "나의 첫 영상",
|
||||
"description": "이 영상은 테스트 영상입니다.",
|
||||
"tags": ["여행", "vlog"],
|
||||
"privacy_status": "private",
|
||||
"platform_options": {
|
||||
"category_id": "22"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"success": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"success": true,
|
||||
"upload_id": 456,
|
||||
"platform": "youtube",
|
||||
"status": "pending",
|
||||
"message": "업로드 요청이 접수되었습니다."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"404_video": {
|
||||
"detail": "영상을 찾을 수 없습니다."
|
||||
},
|
||||
"404_account": {
|
||||
"detail": "youtube 플랫폼에 연동된 계정이 없습니다."
|
||||
},
|
||||
"400": {
|
||||
"detail": "영상이 아직 준비되지 않았습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"youtubeCategoryIds": {
|
||||
"1": "Film & Animation",
|
||||
"2": "Autos & Vehicles",
|
||||
"10": "Music",
|
||||
"15": "Pets & Animals",
|
||||
"17": "Sports",
|
||||
"19": "Travel & Events",
|
||||
"20": "Gaming",
|
||||
"22": "People & Blogs (기본값)",
|
||||
"23": "Comedy",
|
||||
"24": "Entertainment",
|
||||
"25": "News & Politics",
|
||||
"26": "Howto & Style",
|
||||
"27": "Education",
|
||||
"28": "Science & Technology",
|
||||
"29": "Nonprofits & Activism"
|
||||
}
|
||||
},
|
||||
"getStatus": {
|
||||
"name": "업로드 상태 조회",
|
||||
"method": "GET",
|
||||
"url": "/social/upload/{upload_id}/status",
|
||||
"description": "특정 업로드 작업의 상태를 조회합니다. 폴링으로 상태를 확인하세요.",
|
||||
"authentication": true,
|
||||
"pathParameters": {
|
||||
"upload_id": {
|
||||
"type": "integer",
|
||||
"description": "업로드 ID"
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"success": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"upload_id": 456,
|
||||
"video_id": 123,
|
||||
"platform": "youtube",
|
||||
"status": "completed",
|
||||
"upload_progress": 100,
|
||||
"title": "나의 첫 영상",
|
||||
"platform_video_id": "dQw4w9WgXcQ",
|
||||
"platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"error_message": null,
|
||||
"retry_count": 0,
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
"uploaded_at": "2024-01-15T12:05:00"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"404": {
|
||||
"detail": "업로드 정보를 찾을 수 없습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"statusValues": {
|
||||
"pending": "업로드 대기 중",
|
||||
"uploading": "업로드 진행 중 (upload_progress 확인)",
|
||||
"processing": "플랫폼에서 처리 중",
|
||||
"completed": "업로드 완료 (platform_url 사용 가능)",
|
||||
"failed": "업로드 실패 (error_message 확인)",
|
||||
"cancelled": "업로드 취소됨"
|
||||
},
|
||||
"pollingRecommendation": {
|
||||
"interval": "3초",
|
||||
"maxAttempts": 100,
|
||||
"stopConditions": ["completed", "failed", "cancelled"]
|
||||
}
|
||||
},
|
||||
"getHistory": {
|
||||
"name": "업로드 이력 조회",
|
||||
"method": "GET",
|
||||
"url": "/social/upload/history",
|
||||
"description": "사용자의 소셜 미디어 업로드 이력을 조회합니다.",
|
||||
"authentication": true,
|
||||
"queryParameters": {
|
||||
"platform": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"enum": ["youtube"],
|
||||
"description": "플랫폼 필터"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"enum": ["pending", "uploading", "processing", "completed", "failed", "cancelled"],
|
||||
"description": "상태 필터"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 1,
|
||||
"description": "페이지 번호"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"default": 20,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"description": "페이지 크기"
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
},
|
||||
"exampleUrl": "/social/upload/history?platform=youtube&status=completed&page=1&size=20"
|
||||
},
|
||||
"response": {
|
||||
"success": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"items": [
|
||||
{
|
||||
"upload_id": 456,
|
||||
"video_id": 123,
|
||||
"platform": "youtube",
|
||||
"status": "completed",
|
||||
"title": "나의 첫 영상",
|
||||
"platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
"uploaded_at": "2024-01-15T12:05:00"
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"size": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"retry": {
|
||||
"name": "업로드 재시도",
|
||||
"method": "POST",
|
||||
"url": "/social/upload/{upload_id}/retry",
|
||||
"description": "실패한 업로드를 재시도합니다.",
|
||||
"authentication": true,
|
||||
"pathParameters": {
|
||||
"upload_id": {
|
||||
"type": "integer",
|
||||
"description": "업로드 ID"
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"success": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"success": true,
|
||||
"upload_id": 456,
|
||||
"platform": "youtube",
|
||||
"status": "pending",
|
||||
"message": "업로드 재시도가 요청되었습니다."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"400": {
|
||||
"detail": "실패하거나 취소된 업로드만 재시도할 수 있습니다."
|
||||
},
|
||||
"404": {
|
||||
"detail": "업로드 정보를 찾을 수 없습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cancel": {
|
||||
"name": "업로드 취소",
|
||||
"method": "DELETE",
|
||||
"url": "/social/upload/{upload_id}",
|
||||
"description": "대기 중인 업로드를 취소합니다.",
|
||||
"authentication": true,
|
||||
"pathParameters": {
|
||||
"upload_id": {
|
||||
"type": "integer",
|
||||
"description": "업로드 ID"
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"success": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"success": true,
|
||||
"message": "업로드가 취소되었습니다."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"400": {
|
||||
"detail": "대기 중인 업로드만 취소할 수 있습니다."
|
||||
},
|
||||
"404": {
|
||||
"detail": "업로드 정보를 찾을 수 없습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"frontendPages": {
|
||||
"required": [
|
||||
{
|
||||
"path": "/social/connect/success",
|
||||
"description": "OAuth 연동 성공 후 리다이렉트되는 페이지",
|
||||
"queryParams": {
|
||||
"platform": "플랫폼명 (youtube)",
|
||||
"account_id": "연동된 계정 ID",
|
||||
"channel_name": "YouTube 채널 이름 (URL 인코딩됨)",
|
||||
"profile_image": "프로필 이미지 URL (URL 인코딩됨)"
|
||||
},
|
||||
"action": "연동 성공 메시지 표시, 채널 정보 즉시 표시 가능, 이후 GET /social/oauth/accounts 호출로 전체 목록 갱신"
|
||||
},
|
||||
{
|
||||
"path": "/social/connect/error",
|
||||
"description": "OAuth 연동 실패 시 리다이렉트되는 페이지",
|
||||
"queryParams": {
|
||||
"platform": "플랫폼명 (youtube)",
|
||||
"error": "에러 메시지 (URL 인코딩됨)"
|
||||
},
|
||||
"action": "에러 메시지 표시 및 재시도 옵션 제공"
|
||||
}
|
||||
],
|
||||
"recommended": [
|
||||
{
|
||||
"path": "/settings/social",
|
||||
"description": "소셜 계정 관리 페이지",
|
||||
"features": ["연동된 계정 목록", "연동/해제 버튼", "업로드 이력"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"flowExamples": {
|
||||
"connectYouTube": {
|
||||
"description": "YouTube 계정 연동 플로우",
|
||||
"steps": [
|
||||
{
|
||||
"step": 1,
|
||||
"action": "GET /social/oauth/youtube/connect 호출",
|
||||
"result": "auth_url 반환"
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"action": "window.location.href = auth_url",
|
||||
"result": "Google 로그인 페이지로 이동"
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"action": "사용자가 권한 승인",
|
||||
"result": "백엔드 콜백 URL로 자동 리다이렉트"
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"action": "백엔드가 토큰 교환 후 프론트엔드로 리다이렉트",
|
||||
"result": "/social/connect/success?platform=youtube&account_id=1&channel_name=My+Channel&profile_image=https%3A%2F%2F..."
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"action": "URL 파라미터에서 채널 정보 추출하여 즉시 표시",
|
||||
"code": "const params = new URLSearchParams(window.location.search); const channelName = params.get('channel_name'); const profileImage = params.get('profile_image');"
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"action": "GET /social/oauth/accounts 호출로 전체 계정 목록 갱신",
|
||||
"result": "연동된 계정 정보 표시"
|
||||
}
|
||||
]
|
||||
},
|
||||
"uploadVideo": {
|
||||
"description": "YouTube 영상 업로드 플로우",
|
||||
"steps": [
|
||||
{
|
||||
"step": 1,
|
||||
"action": "POST /social/upload 호출",
|
||||
"request": {
|
||||
"video_id": 123,
|
||||
"platform": "youtube",
|
||||
"title": "영상 제목",
|
||||
"privacy_status": "private"
|
||||
},
|
||||
"result": "upload_id 반환"
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"action": "GET /social/upload/{upload_id}/status 폴링 (3초 간격)",
|
||||
"result": "status, upload_progress 확인"
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"action": "status === 'completed' 확인",
|
||||
"result": "platform_url로 YouTube 링크 표시"
|
||||
}
|
||||
],
|
||||
"pollingCode": "setInterval(() => checkUploadStatus(uploadId), 3000)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
-- ===================================================================
|
||||
-- social_upload 테이블 생성 마이그레이션
|
||||
-- 소셜 미디어 업로드 기록을 저장하는 테이블
|
||||
-- 생성일: 2026-02-02
|
||||
-- ===================================================================
|
||||
|
||||
-- social_upload 테이블 생성
|
||||
CREATE TABLE IF NOT EXISTS social_upload (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '고유 식별자',
|
||||
|
||||
-- 관계 필드
|
||||
user_uuid VARCHAR(36) NOT NULL COMMENT '사용자 UUID (User.user_uuid 참조)',
|
||||
video_id INT NOT NULL COMMENT 'Video 외래키',
|
||||
social_account_id INT NOT NULL COMMENT 'SocialAccount 외래키',
|
||||
|
||||
-- 플랫폼 정보
|
||||
platform VARCHAR(20) NOT NULL COMMENT '플랫폼 구분 (youtube, instagram, facebook, tiktok)',
|
||||
|
||||
-- 업로드 상태
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '업로드 상태 (pending, uploading, processing, completed, failed)',
|
||||
upload_progress INT NOT NULL DEFAULT 0 COMMENT '업로드 진행률 (0-100)',
|
||||
|
||||
-- 플랫폼 결과
|
||||
platform_video_id VARCHAR(100) NULL COMMENT '플랫폼에서 부여한 영상 ID',
|
||||
platform_url VARCHAR(500) NULL COMMENT '플랫폼에서의 영상 URL',
|
||||
|
||||
-- 메타데이터
|
||||
title VARCHAR(200) NOT NULL COMMENT '영상 제목',
|
||||
description TEXT NULL COMMENT '영상 설명',
|
||||
tags JSON NULL COMMENT '태그 목록 (JSON 배열)',
|
||||
privacy_status VARCHAR(20) NOT NULL DEFAULT 'private' COMMENT '공개 상태 (public, unlisted, private)',
|
||||
platform_options JSON NULL COMMENT '플랫폼별 추가 옵션 (JSON)',
|
||||
|
||||
-- 에러 정보
|
||||
error_message TEXT NULL COMMENT '에러 메시지 (실패 시)',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '재시도 횟수',
|
||||
|
||||
-- 시간 정보
|
||||
uploaded_at DATETIME NULL COMMENT '업로드 완료 시간',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시',
|
||||
|
||||
-- 외래키 제약조건
|
||||
CONSTRAINT fk_social_upload_user FOREIGN KEY (user_uuid) REFERENCES user(user_uuid) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_social_upload_video FOREIGN KEY (video_id) REFERENCES video(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_social_upload_account FOREIGN KEY (social_account_id) REFERENCES social_account(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='소셜 미디어 업로드 기록 테이블';
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_social_upload_user_uuid ON social_upload(user_uuid);
|
||||
CREATE INDEX idx_social_upload_video_id ON social_upload(video_id);
|
||||
CREATE INDEX idx_social_upload_social_account_id ON social_upload(social_account_id);
|
||||
CREATE INDEX idx_social_upload_platform ON social_upload(platform);
|
||||
CREATE INDEX idx_social_upload_status ON social_upload(status);
|
||||
CREATE INDEX idx_social_upload_created_at ON social_upload(created_at);
|
||||
|
||||
-- 유니크 인덱스 (동일 영상 + 동일 계정 조합은 하나만 존재)
|
||||
CREATE UNIQUE INDEX uq_social_upload_video_platform ON social_upload(video_id, social_account_id);
|
||||
58
main.py
58
main.py
|
|
@ -17,8 +17,6 @@ from app.user.api.routers.v1.auth import router as auth_router, test_router as a
|
|||
from app.lyric.api.routers.v1.lyric import router as lyric_router
|
||||
from app.song.api.routers.v1.song import router as song_router
|
||||
from app.video.api.routers.v1.video import router as video_router
|
||||
from app.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.utils.cors import CustomCORSMiddleware
|
||||
from config import prj_settings
|
||||
|
||||
|
|
@ -153,55 +151,6 @@ tags_metadata = [
|
|||
- created_at 기준 내림차순 정렬됩니다.
|
||||
- 삭제는 소프트 삭제(is_deleted=True) 방식으로 처리되며, 데이터 복구가 가능합니다.
|
||||
- 삭제 대상: Video, SongTimestamp, Song, Lyric, Image, Project
|
||||
""",
|
||||
},
|
||||
{
|
||||
"name": "Social OAuth",
|
||||
"description": """소셜 미디어 계정 연동 API
|
||||
|
||||
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
|
||||
|
||||
## 지원 플랫폼
|
||||
|
||||
- **YouTube**: Google OAuth 2.0 기반 연동
|
||||
|
||||
## 연동 흐름
|
||||
|
||||
1. `GET /social/oauth/{platform}/connect` - OAuth 인증 URL 획득
|
||||
2. 사용자를 auth_url로 리다이렉트 → 플랫폼 로그인
|
||||
3. 플랫폼에서 권한 승인 후 콜백 URL로 리다이렉트
|
||||
4. 연동 완료 후 프론트엔드로 리다이렉트
|
||||
|
||||
## 계정 관리
|
||||
|
||||
- `GET /social/oauth/accounts` - 연동된 계정 목록 조회
|
||||
- `DELETE /social/oauth/{platform}/disconnect` - 계정 연동 해제
|
||||
""",
|
||||
},
|
||||
{
|
||||
"name": "Social Upload",
|
||||
"description": """소셜 미디어 영상 업로드 API
|
||||
|
||||
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
|
||||
|
||||
## 사전 조건
|
||||
|
||||
- 해당 플랫폼에 계정이 연동되어 있어야 합니다
|
||||
- 영상이 completed 상태여야 합니다
|
||||
|
||||
## 업로드 흐름
|
||||
|
||||
1. `POST /social/upload` - 업로드 요청 (백그라운드 처리)
|
||||
2. `GET /social/upload/{upload_id}/status` - 업로드 상태 폴링
|
||||
3. `GET /social/upload/history` - 업로드 이력 조회
|
||||
|
||||
## 업로드 상태
|
||||
|
||||
- `pending`: 업로드 대기 중
|
||||
- `uploading`: 업로드 진행 중
|
||||
- `processing`: 플랫폼에서 처리 중
|
||||
- `completed`: 업로드 완료
|
||||
- `failed`: 업로드 실패
|
||||
""",
|
||||
},
|
||||
]
|
||||
|
|
@ -269,7 +218,6 @@ def custom_openapi():
|
|||
"/crawling",
|
||||
"/autocomplete",
|
||||
"/search", # 숙박 검색 자동완성
|
||||
"/social/oauth/youtube/callback", # OAuth 콜백 (플랫폼에서 직접 호출)
|
||||
]
|
||||
|
||||
# 보안이 필요한 엔드포인트에 security 적용
|
||||
|
|
@ -313,18 +261,12 @@ def get_scalar_docs():
|
|||
)
|
||||
|
||||
|
||||
# 예외 핸들러 등록
|
||||
from app.core.exceptions import add_exception_handlers
|
||||
add_exception_handlers(app)
|
||||
|
||||
app.include_router(home_router)
|
||||
app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
|
||||
app.include_router(lyric_router)
|
||||
app.include_router(song_router)
|
||||
app.include_router(video_router)
|
||||
app.include_router(archive_router) # Archive API 라우터 추가
|
||||
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가
|
||||
app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가
|
||||
|
||||
# DEBUG 모드에서만 테스트 라우터 등록
|
||||
if prj_settings.DEBUG:
|
||||
|
|
|
|||
Loading…
Reference in New Issue