Merge branch 'main' into creatomate

get_video
jaehwang 2026-02-02 07:58:19 +00:00
commit e1386b891e
31 changed files with 4917 additions and 63 deletions

View File

@ -37,7 +37,7 @@ router = APIRouter(prefix="/archive", tags=["Archive"])
- **page_size**: 페이지당 데이터 (기본값: 10, 최대: 100)
## 반환 정보
- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at)
- **items**: 영상 목록 (video_id, 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,35 +149,21 @@ async def get_videos(
Project.is_deleted == False,
]
# 쿼리 1: 전체 개수 조회 (task_id 기준 고유 개수)
# 쿼리 1: 전체 개수 조회 (모든 영상)
count_query = (
select(func.count(func.distinct(Video.task_id)))
select(func.count(Video.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 - task_id 기준 고유 개수 (total): {total}")
logger.debug(f"[get_videos] DEBUG - 전체 영상 개수 (total): {total}")
# 서브쿼리: 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으로 한 번에 조회
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회 (모든 영상)
query = (
select(Video, Project)
.join(Project, Video.project_id == Project.id)
.where(Video.id.in_(select(subquery.c.max_id)))
.where(*base_conditions)
.order_by(Video.created_at.desc())
.offset(offset)
.limit(pagination.page_size)
@ -190,6 +176,7 @@ 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,
@ -219,9 +206,94 @@ 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="아카이브 영상 소프트 삭제",
summary="프로젝트 전체 소프트 삭제 (task_id 기준)",
description="""
## 개요
task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트 삭제합니다.
@ -242,6 +314,7 @@ task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트
- 본인이 소유한 프로젝트만 삭제할 있습니다.
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
- 백그라운드에서 비동기로 처리됩니다.
- **개별 영상만 삭제하려면 DELETE /archive/videos/{video_id} 사용하세요.**
""",
responses={
200: {"description": "삭제 요청 성공"},

View File

@ -288,6 +288,20 @@ 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(

View File

@ -292,10 +292,21 @@ 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,
@ -307,6 +318,7 @@ 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)")
@ -340,6 +352,7 @@ 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

View File

@ -23,6 +23,7 @@ async def _update_lyric_status(
task_id: str,
status: str,
result: str | None = None,
lyric_id: int | None = None,
) -> bool:
"""Lyric 테이블의 상태를 업데이트합니다.
@ -30,12 +31,20 @@ 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)
@ -49,17 +58,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}, status: {status}")
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
return True
else:
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}, lyric_id: {lyric_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, lyric_id: {lyric_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, lyric_id: {lyric_id}, error: {e}")
return False
@ -67,13 +76,15 @@ 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에 전달할 프롬프트
language: 가사 언어
lyric_input_data: 프롬프트 입력 데이터
lyric_id: 특정 Lyric 레코드 ID (재생성 정확한 레코드 식별용)
"""
import time
@ -116,7 +127,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)
await _update_lyric_status(task_id, "completed", result, lyric_id)
step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
@ -136,14 +147,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}")
await _update_lyric_status(task_id, "failed", f"ChatGPT Error: {e.error_message}", lyric_id)
except SQLAlchemyError as e:
elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}")
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}", lyric_id)
except Exception as e:
elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)

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

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
"""
Social API Routers v1
"""
from app.social.api.routers.v1.oauth import router as oauth_router
from app.social.api.routers.v1.upload import router as upload_router
__all__ = ["oauth_router", "upload_router"]

View File

@ -0,0 +1,327 @@
"""
소셜 OAuth API 라우터
소셜 미디어 계정 연동 관련 엔드포인트를 제공합니다.
"""
import logging
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Query
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from config import social_oauth_settings
from app.database.session import get_session
from app.social.constants import SocialPlatform
from app.social.schemas import (
MessageResponse,
SocialAccountListResponse,
SocialAccountResponse,
SocialConnectResponse,
)
from app.social.services import social_account_service
from app.user.dependencies import get_current_user
from app.user.models import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/oauth", tags=["Social OAuth"])
def _build_redirect_url(is_success: bool, params: dict) -> str:
"""OAuth 리다이렉트 URL 생성"""
base_url = social_oauth_settings.OAUTH_FRONTEND_URL.rstrip("/")
path = (
social_oauth_settings.OAUTH_SUCCESS_PATH
if is_success
else social_oauth_settings.OAUTH_ERROR_PATH
)
return f"{base_url}{path}?{urlencode(params)}"
@router.get(
"/{platform}/connect",
response_model=SocialConnectResponse,
summary="소셜 계정 연동 시작",
description="""
소셜 미디어 계정 연동을 시작합니다.
## 지원 플랫폼
- **youtube**: YouTube (Google OAuth)
- instagram, facebook, tiktok: 추후 지원 예정
## 플로우
1. 엔드포인트를 호출하여 `auth_url` `state` 받음
2. 프론트엔드에서 `auth_url` 사용자를 리다이렉트
3. 사용자가 플랫폼에서 권한 승인
4. 플랫폼이 `/callback` 엔드포인트로 리다이렉트
5. 연동 완료 프론트엔드로 리다이렉트
""",
)
async def start_connect(
platform: SocialPlatform,
current_user: User = Depends(get_current_user),
) -> SocialConnectResponse:
"""
소셜 계정 연동 시작
OAuth 인증 URL을 생성하고 state 토큰을 반환합니다.
프론트엔드에서 반환된 auth_url로 사용자를 리다이렉트하면 됩니다.
"""
logger.info(
f"[OAUTH_API] 소셜 연동 시작 - "
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
)
return await social_account_service.start_connect(
user_uuid=current_user.user_uuid,
platform=platform,
)
@router.get(
"/{platform}/callback",
summary="OAuth 콜백",
description="""
소셜 플랫폼의 OAuth 콜백을 처리합니다.
엔드포인트는 소셜 플랫폼에서 직접 호출되며,
사용자를 프론트엔드로 리다이렉트합니다.
""",
)
async def oauth_callback(
platform: SocialPlatform,
code: str | None = Query(None, description="OAuth 인가 코드"),
state: str | None = Query(None, description="CSRF 방지용 state 토큰"),
error: str | None = Query(None, description="OAuth 에러 코드 (사용자 취소 등)"),
error_description: str | None = Query(None, description="OAuth 에러 설명"),
session: AsyncSession = Depends(get_session),
) -> RedirectResponse:
"""
OAuth 콜백 처리
소셜 플랫폼에서 리다이렉트된 호출됩니다.
인가 코드로 토큰을 교환하고 계정을 연동합니다.
"""
# 사용자가 취소하거나 에러가 발생한 경우
if error:
logger.info(
f"[OAUTH_API] OAuth 취소/에러 - "
f"platform: {platform.value}, error: {error}, description: {error_description}"
)
# 에러 메시지 생성
if error == "access_denied":
error_message = "사용자가 연동을 취소했습니다."
else:
error_message = error_description or error
redirect_url = _build_redirect_url(
is_success=False,
params={
"platform": platform.value,
"error": error_message,
"cancelled": "true" if error == "access_denied" else "false",
},
)
return RedirectResponse(url=redirect_url, status_code=302)
# code나 state가 없는 경우
if not code or not state:
logger.warning(
f"[OAUTH_API] OAuth 콜백 파라미터 누락 - "
f"platform: {platform.value}, code: {bool(code)}, state: {bool(state)}"
)
redirect_url = _build_redirect_url(
is_success=False,
params={
"platform": platform.value,
"error": "잘못된 요청입니다. 다시 시도해주세요.",
},
)
return RedirectResponse(url=redirect_url, status_code=302)
logger.info(
f"[OAUTH_API] OAuth 콜백 수신 - "
f"platform: {platform.value}, code: {code[:20]}..."
)
try:
account = await social_account_service.handle_callback(
code=code,
state=state,
session=session,
)
# 성공 시 프론트엔드로 리다이렉트 (계정 정보 포함)
redirect_url = _build_redirect_url(
is_success=True,
params={
"platform": platform.value,
"account_id": account.id,
"channel_name": account.display_name or account.platform_username or "",
"profile_image": account.profile_image_url or "",
},
)
logger.info(f"[OAUTH_API] 연동 성공, 리다이렉트 - url: {redirect_url}")
return RedirectResponse(url=redirect_url, status_code=302)
except Exception as e:
logger.error(f"[OAUTH_API] OAuth 콜백 처리 실패 - error: {e}")
# 실패 시 에러 페이지로 리다이렉트
redirect_url = _build_redirect_url(
is_success=False,
params={
"platform": platform.value,
"error": str(e),
},
)
return RedirectResponse(url=redirect_url, status_code=302)
@router.get(
"/accounts",
response_model=SocialAccountListResponse,
summary="연동된 소셜 계정 목록 조회",
description="현재 사용자가 연동한 모든 소셜 계정 목록을 반환합니다.",
)
async def get_connected_accounts(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SocialAccountListResponse:
"""
연동된 소셜 계정 목록 조회
현재 로그인한 사용자가 연동한 모든 소셜 계정을 조회합니다.
"""
logger.info(f"[OAUTH_API] 연동 계정 목록 조회 - user_uuid: {current_user.user_uuid}")
accounts = await social_account_service.get_connected_accounts(
user_uuid=current_user.user_uuid,
session=session,
)
return SocialAccountListResponse(
accounts=accounts,
total=len(accounts),
)
@router.get(
"/accounts/{platform}",
response_model=SocialAccountResponse,
summary="특정 플랫폼 연동 계정 조회",
description="특정 플랫폼에 연동된 계정 정보를 반환합니다.",
)
async def get_account_by_platform(
platform: SocialPlatform,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SocialAccountResponse:
"""
특정 플랫폼 연동 계정 조회
"""
logger.info(
f"[OAUTH_API] 특정 플랫폼 계정 조회 - "
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
)
account = await social_account_service.get_account_by_platform(
user_uuid=current_user.user_uuid,
platform=platform,
session=session,
)
if account is None:
from app.social.exceptions import SocialAccountNotFoundError
raise SocialAccountNotFoundError(platform=platform.value)
return social_account_service._to_response(account)
@router.delete(
"/accounts/{account_id}",
response_model=MessageResponse,
summary="소셜 계정 연동 해제 (account_id)",
description="""
소셜 미디어 계정 연동을 해제합니다.
## 경로 파라미터
- **account_id**: 연동 해제할 소셜 계정 ID (SocialAccount.id)
## 연동 해제 시
- 해당 플랫폼으로의 업로드가 불가능해집니다
- 기존 업로드 기록은 유지됩니다
- 재연동 동의 화면이 스킵됩니다
""",
)
async def disconnect_by_account_id(
account_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MessageResponse:
"""
소셜 계정 연동 해제 (account_id 기준)
account_id로 특정 소셜 계정의 연동을 해제합니다.
"""
logger.info(
f"[OAUTH_API] 소셜 연동 해제 (by account_id) - "
f"user_uuid: {current_user.user_uuid}, account_id: {account_id}"
)
platform = await social_account_service.disconnect_by_account_id(
user_uuid=current_user.user_uuid,
account_id=account_id,
session=session,
)
return MessageResponse(
success=True,
message=f"{platform} 계정 연동이 해제되었습니다.",
)
@router.delete(
"/{platform}/disconnect",
response_model=MessageResponse,
summary="소셜 계정 연동 해제 (platform)",
description="""
소셜 미디어 계정 연동을 해제합니다.
**주의**: API는 플랫폼당 1개의 계정만 연동된 경우에 사용합니다.
여러 채널이 연동된 경우 `DELETE /accounts/{account_id}` 사용하세요.
연동 해제 :
- 해당 플랫폼으로의 업로드가 불가능해집니다
- 기존 업로드 기록은 유지됩니다
""",
deprecated=True,
)
async def disconnect(
platform: SocialPlatform,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MessageResponse:
"""
소셜 계정 연동 해제 (platform 기준)
플랫폼으로 연동된 번째 계정을 해제합니다.
"""
logger.info(
f"[OAUTH_API] 소셜 연동 해제 - "
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
)
await social_account_service.disconnect(
user_uuid=current_user.user_uuid,
platform=platform,
session=session,
)
return MessageResponse(
success=True,
message=f"{platform.value} 계정 연동이 해제되었습니다.",
)

View File

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

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

@ -0,0 +1,123 @@
"""
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",
# ]

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

@ -0,0 +1,330 @@
"""
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",
)

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

@ -0,0 +1,245 @@
"""
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")>"
)

View File

@ -0,0 +1,46 @@
"""
Social OAuth Module
소셜 미디어 OAuth 클라이언트 모듈입니다.
"""
from app.social.constants import SocialPlatform
from app.social.oauth.base import BaseOAuthClient
def get_oauth_client(platform: SocialPlatform) -> BaseOAuthClient:
"""
플랫폼에 맞는 OAuth 클라이언트 반환
Args:
platform: 소셜 플랫폼
Returns:
BaseOAuthClient: OAuth 클라이언트 인스턴스
Raises:
ValueError: 지원하지 않는 플랫폼인 경우
"""
if platform == SocialPlatform.YOUTUBE:
from app.social.oauth.youtube import YouTubeOAuthClient
return YouTubeOAuthClient()
# 추후 확장
# elif platform == SocialPlatform.INSTAGRAM:
# from app.social.oauth.instagram import InstagramOAuthClient
# return InstagramOAuthClient()
# elif platform == SocialPlatform.FACEBOOK:
# from app.social.oauth.facebook import FacebookOAuthClient
# return FacebookOAuthClient()
# elif platform == SocialPlatform.TIKTOK:
# from app.social.oauth.tiktok import TikTokOAuthClient
# return TikTokOAuthClient()
raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}")
__all__ = [
"BaseOAuthClient",
"get_oauth_client",
]

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

@ -0,0 +1,113 @@
"""
Base OAuth Client
소셜 미디어 OAuth 클라이언트의 추상 기본 클래스입니다.
"""
from abc import ABC, abstractmethod
from typing import Optional
from app.social.constants import SocialPlatform
from app.social.schemas import OAuthTokenResponse, PlatformUserInfo
class BaseOAuthClient(ABC):
"""
소셜 미디어 OAuth 클라이언트 추상 기본 클래스
모든 플랫폼별 OAuth 클라이언트는 클래스를 상속받아 구현합니다.
Attributes:
platform: 소셜 플랫폼 종류
"""
platform: SocialPlatform
@abstractmethod
def get_authorization_url(self, state: str) -> str:
"""
OAuth 인증 URL 생성
Args:
state: CSRF 방지용 state 토큰
Returns:
str: OAuth 인증 페이지 URL
"""
pass
@abstractmethod
async def exchange_code(self, code: str) -> OAuthTokenResponse:
"""
인가 코드로 액세스 토큰 교환
Args:
code: OAuth 인가 코드
Returns:
OAuthTokenResponse: 액세스 토큰 리프레시 토큰
Raises:
OAuthCodeExchangeError: 토큰 교환 실패
"""
pass
@abstractmethod
async def refresh_token(self, refresh_token: str) -> OAuthTokenResponse:
"""
리프레시 토큰으로 액세스 토큰 갱신
Args:
refresh_token: 리프레시 토큰
Returns:
OAuthTokenResponse: 액세스 토큰
Raises:
OAuthTokenRefreshError: 토큰 갱신 실패
"""
pass
@abstractmethod
async def get_user_info(self, access_token: str) -> PlatformUserInfo:
"""
플랫폼 사용자 정보 조회
Args:
access_token: 액세스 토큰
Returns:
PlatformUserInfo: 플랫폼 사용자 정보
Raises:
SocialAccountError: 사용자 정보 조회 실패
"""
pass
@abstractmethod
async def revoke_token(self, token: str) -> bool:
"""
토큰 폐기 (연동 해제 )
Args:
token: 폐기할 토큰
Returns:
bool: 폐기 성공 여부
"""
pass
def is_token_expired(self, expires_in: Optional[int]) -> bool:
"""
토큰 만료 여부 확인 (만료 10 전이면 True)
Args:
expires_in: 토큰 만료까지 남은 시간()
Returns:
bool: 갱신 필요 여부
"""
if expires_in is None:
return False
# 만료 10분(600초) 전이면 갱신 필요
return expires_in <= 600

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

@ -0,0 +1,326 @@
"""
YouTube OAuth Client
Google OAuth를 사용한 YouTube 인증 클라이언트입니다.
"""
import logging
from urllib.parse import urlencode
import httpx
from config import social_oauth_settings
from app.social.constants import SocialPlatform, YOUTUBE_SCOPES
from app.social.exceptions import (
OAuthCodeExchangeError,
OAuthTokenRefreshError,
SocialAccountError,
)
from app.social.oauth.base import BaseOAuthClient
from app.social.schemas import OAuthTokenResponse, PlatformUserInfo
logger = logging.getLogger(__name__)
class YouTubeOAuthClient(BaseOAuthClient):
"""
YouTube OAuth 클라이언트
Google OAuth 2.0 사용하여 YouTube 계정 인증을 처리합니다.
"""
platform = SocialPlatform.YOUTUBE
# Google OAuth 엔드포인트
AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_URL = "https://oauth2.googleapis.com/token"
USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
YOUTUBE_CHANNEL_URL = "https://www.googleapis.com/youtube/v3/channels"
REVOKE_URL = "https://oauth2.googleapis.com/revoke"
def __init__(self) -> None:
self.client_id = social_oauth_settings.YOUTUBE_CLIENT_ID
self.client_secret = social_oauth_settings.YOUTUBE_CLIENT_SECRET
self.redirect_uri = social_oauth_settings.YOUTUBE_REDIRECT_URI
def get_authorization_url(self, state: str) -> str:
"""
Google OAuth 인증 URL 생성
Args:
state: CSRF 방지용 state 토큰
Returns:
str: Google OAuth 인증 페이지 URL
"""
params = {
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"response_type": "code",
"scope": " ".join(YOUTUBE_SCOPES),
"access_type": "offline", # refresh_token 받기 위해 필요
"prompt": "select_account", # 계정 선택만 표시 (이전 동의 유지)
"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()

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

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

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

@ -0,0 +1,611 @@
"""
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()

View File

@ -0,0 +1,47 @@
"""
Social Uploader Module
소셜 미디어 영상 업로더 모듈입니다.
"""
from app.social.constants import SocialPlatform
from app.social.uploader.base import BaseSocialUploader, UploadResult
def get_uploader(platform: SocialPlatform) -> BaseSocialUploader:
"""
플랫폼에 맞는 업로더 반환
Args:
platform: 소셜 플랫폼
Returns:
BaseSocialUploader: 업로더 인스턴스
Raises:
ValueError: 지원하지 않는 플랫폼인 경우
"""
if platform == SocialPlatform.YOUTUBE:
from app.social.uploader.youtube import YouTubeUploader
return YouTubeUploader()
# 추후 확장
# elif platform == SocialPlatform.INSTAGRAM:
# from app.social.uploader.instagram import InstagramUploader
# return InstagramUploader()
# elif platform == SocialPlatform.FACEBOOK:
# from app.social.uploader.facebook import FacebookUploader
# return FacebookUploader()
# elif platform == SocialPlatform.TIKTOK:
# from app.social.uploader.tiktok import TikTokUploader
# return TikTokUploader()
raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}")
__all__ = [
"BaseSocialUploader",
"UploadResult",
"get_uploader",
]

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

@ -0,0 +1,168 @@
"""
Base Social Uploader
소셜 미디어 영상 업로더의 추상 기본 클래스입니다.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Callable, Optional
from app.social.constants import PrivacyStatus, SocialPlatform
@dataclass
class UploadMetadata:
"""
업로드 메타데이터
영상 업로드 필요한 메타데이터를 정의합니다.
Attributes:
title: 영상 제목
description: 영상 설명
tags: 태그 목록
privacy_status: 공개 상태
platform_options: 플랫폼별 추가 옵션
"""
title: str
description: Optional[str] = None
tags: Optional[list[str]] = None
privacy_status: PrivacyStatus = PrivacyStatus.PRIVATE
platform_options: Optional[dict[str, Any]] = None
@dataclass
class UploadResult:
"""
업로드 결과
Attributes:
success: 성공 여부
platform_video_id: 플랫폼에서 부여한 영상 ID
platform_url: 플랫폼에서의 영상 URL
error_message: 에러 메시지 (실패 )
platform_response: 플랫폼 원본 응답 (디버깅용)
"""
success: bool
platform_video_id: Optional[str] = None
platform_url: Optional[str] = None
error_message: Optional[str] = None
platform_response: Optional[dict[str, Any]] = None
class BaseSocialUploader(ABC):
"""
소셜 미디어 영상 업로더 추상 기본 클래스
모든 플랫폼별 업로더는 클래스를 상속받아 구현합니다.
Attributes:
platform: 소셜 플랫폼 종류
"""
platform: SocialPlatform
@abstractmethod
async def upload(
self,
video_path: str,
access_token: str,
metadata: UploadMetadata,
progress_callback: Optional[Callable[[int], None]] = None,
) -> UploadResult:
"""
영상 업로드
Args:
video_path: 업로드할 영상 파일 경로 (로컬 또는 URL)
access_token: OAuth 액세스 토큰
metadata: 업로드 메타데이터
progress_callback: 진행률 콜백 함수 (0-100)
Returns:
UploadResult: 업로드 결과
"""
pass
@abstractmethod
async def get_upload_status(
self,
platform_video_id: str,
access_token: str,
) -> dict[str, Any]:
"""
업로드 상태 조회
플랫폼에서 영상 처리 상태를 조회합니다.
Args:
platform_video_id: 플랫폼 영상 ID
access_token: OAuth 액세스 토큰
Returns:
dict: 업로드 상태 정보
"""
pass
@abstractmethod
async def delete_video(
self,
platform_video_id: str,
access_token: str,
) -> bool:
"""
업로드된 영상 삭제
Args:
platform_video_id: 플랫폼 영상 ID
access_token: OAuth 액세스 토큰
Returns:
bool: 삭제 성공 여부
"""
pass
def validate_metadata(self, metadata: UploadMetadata) -> None:
"""
메타데이터 유효성 검증
플랫폼별 제한사항을 확인합니다.
Args:
metadata: 검증할 메타데이터
Raises:
ValueError: 유효하지 않은 메타데이터
"""
if not metadata.title or len(metadata.title) == 0:
raise ValueError("제목은 필수입니다.")
if len(metadata.title) > 100:
raise ValueError("제목은 100자를 초과할 수 없습니다.")
if metadata.description and len(metadata.description) > 5000:
raise ValueError("설명은 5000자를 초과할 수 없습니다.")
def get_video_url(self, platform_video_id: str) -> str:
"""
플랫폼 영상 URL 생성
Args:
platform_video_id: 플랫폼 영상 ID
Returns:
str: 영상 URL
"""
if self.platform == SocialPlatform.YOUTUBE:
return f"https://www.youtube.com/watch?v={platform_video_id}"
elif self.platform == SocialPlatform.INSTAGRAM:
return f"https://www.instagram.com/reel/{platform_video_id}/"
elif self.platform == SocialPlatform.FACEBOOK:
return f"https://www.facebook.com/watch/?v={platform_video_id}"
elif self.platform == SocialPlatform.TIKTOK:
return f"https://www.tiktok.com/video/{platform_video_id}"
else:
return ""

View File

@ -0,0 +1,420 @@
"""
YouTube Uploader
YouTube Data API v3를 사용한 영상 업로더입니다.
Resumable Upload를 지원합니다.
"""
import json
import logging
import os
from typing import Any, Callable, Optional
import httpx
from config import social_upload_settings
from app.social.constants import PrivacyStatus, SocialPlatform
from app.social.exceptions import UploadError, UploadQuotaExceededError
from app.social.uploader.base import BaseSocialUploader, UploadMetadata, UploadResult
logger = logging.getLogger(__name__)
class YouTubeUploader(BaseSocialUploader):
"""
YouTube 영상 업로더
YouTube Data API v3의 Resumable Upload를 사용하여
대용량 영상을 안정적으로 업로드합니다.
"""
platform = SocialPlatform.YOUTUBE
# YouTube API 엔드포인트
UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos"
VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"
# 청크 크기 (5MB - YouTube 권장)
CHUNK_SIZE = 5 * 1024 * 1024
def __init__(self) -> None:
self.timeout = social_upload_settings.UPLOAD_TIMEOUT_SECONDS
async def upload(
self,
video_path: str,
access_token: str,
metadata: UploadMetadata,
progress_callback: Optional[Callable[[int], None]] = None,
) -> UploadResult:
"""
YouTube에 영상 업로드 (Resumable Upload)
Args:
video_path: 업로드할 영상 파일 경로
access_token: OAuth 액세스 토큰
metadata: 업로드 메타데이터
progress_callback: 진행률 콜백 함수 (0-100)
Returns:
UploadResult: 업로드 결과
"""
logger.info(f"[YOUTUBE_UPLOAD] 업로드 시작 - video_path: {video_path}")
# 1. 메타데이터 유효성 검증
self.validate_metadata(metadata)
# 2. 파일 크기 확인
if not os.path.exists(video_path):
logger.error(f"[YOUTUBE_UPLOAD] 파일 없음 - path: {video_path}")
return UploadResult(
success=False,
error_message=f"파일을 찾을 수 없습니다: {video_path}",
)
file_size = os.path.getsize(video_path)
logger.info(f"[YOUTUBE_UPLOAD] 파일 크기: {file_size / (1024*1024):.2f} MB")
try:
# 3. Resumable upload 세션 시작
upload_url = await self._init_resumable_upload(
access_token=access_token,
metadata=metadata,
file_size=file_size,
)
# 4. 파일 업로드
video_id = await self._upload_file(
upload_url=upload_url,
video_path=video_path,
file_size=file_size,
progress_callback=progress_callback,
)
video_url = self.get_video_url(video_id)
logger.info(
f"[YOUTUBE_UPLOAD] 업로드 성공 - video_id: {video_id}, url: {video_url}"
)
return UploadResult(
success=True,
platform_video_id=video_id,
platform_url=video_url,
)
except UploadQuotaExceededError:
raise
except UploadError as e:
logger.error(f"[YOUTUBE_UPLOAD] 업로드 실패 - error: {e}")
return UploadResult(
success=False,
error_message=str(e),
)
except Exception as e:
logger.error(f"[YOUTUBE_UPLOAD] 예상치 못한 에러 - error: {e}")
return UploadResult(
success=False,
error_message=f"업로드 중 에러 발생: {str(e)}",
)
async def _init_resumable_upload(
self,
access_token: str,
metadata: UploadMetadata,
file_size: int,
) -> str:
"""
Resumable upload 세션 시작
Args:
access_token: OAuth 액세스 토큰
metadata: 업로드 메타데이터
file_size: 파일 크기
Returns:
str: 업로드 URL
Raises:
UploadError: 세션 시작 실패
"""
logger.debug("[YOUTUBE_UPLOAD] Resumable upload 세션 시작")
# YouTube API 요청 본문
body = {
"snippet": {
"title": metadata.title,
"description": metadata.description or "",
"tags": metadata.tags or [],
"categoryId": self._get_category_id(metadata),
},
"status": {
"privacyStatus": self._convert_privacy_status(metadata.privacy_status),
"selfDeclaredMadeForKids": False,
},
}
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json; charset=utf-8",
"X-Upload-Content-Type": "video/*",
"X-Upload-Content-Length": str(file_size),
}
params = {
"uploadType": "resumable",
"part": "snippet,status",
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
self.UPLOAD_URL,
params=params,
headers=headers,
json=body,
)
if response.status_code == 200:
upload_url = response.headers.get("location")
if upload_url:
logger.debug(
f"[YOUTUBE_UPLOAD] 세션 시작 성공 - upload_url: {upload_url[:50]}..."
)
return upload_url
# 에러 처리
error_data = response.json() if response.content else {}
error_reason = (
error_data.get("error", {}).get("errors", [{}])[0].get("reason", "")
)
if error_reason == "quotaExceeded":
logger.error("[YOUTUBE_UPLOAD] API 할당량 초과")
raise UploadQuotaExceededError(platform=self.platform.value)
error_message = error_data.get("error", {}).get(
"message", f"HTTP {response.status_code}"
)
logger.error(f"[YOUTUBE_UPLOAD] 세션 시작 실패 - error: {error_message}")
raise UploadError(
platform=self.platform.value,
detail=f"Resumable upload 세션 시작 실패: {error_message}",
)
async def _upload_file(
self,
upload_url: str,
video_path: str,
file_size: int,
progress_callback: Optional[Callable[[int], None]] = None,
) -> str:
"""
파일 청크 업로드
Args:
upload_url: Resumable upload URL
video_path: 영상 파일 경로
file_size: 파일 크기
progress_callback: 진행률 콜백
Returns:
str: YouTube 영상 ID
Raises:
UploadError: 업로드 실패
"""
uploaded_bytes = 0
async with httpx.AsyncClient(timeout=self.timeout) as client:
with open(video_path, "rb") as video_file:
while uploaded_bytes < file_size:
# 청크 읽기
chunk = video_file.read(self.CHUNK_SIZE)
chunk_size = len(chunk)
end_byte = uploaded_bytes + chunk_size - 1
headers = {
"Content-Type": "video/*",
"Content-Length": str(chunk_size),
"Content-Range": f"bytes {uploaded_bytes}-{end_byte}/{file_size}",
}
response = await client.put(
upload_url,
headers=headers,
content=chunk,
)
if response.status_code == 200 or response.status_code == 201:
# 업로드 완료
result = response.json()
video_id = result.get("id")
if video_id:
return video_id
raise UploadError(
platform=self.platform.value,
detail="응답에서 video ID를 찾을 수 없습니다.",
)
elif response.status_code == 308:
# 청크 업로드 성공, 계속 진행
uploaded_bytes += chunk_size
progress = int((uploaded_bytes / file_size) * 100)
if progress_callback:
progress_callback(progress)
logger.debug(
f"[YOUTUBE_UPLOAD] 청크 업로드 완료 - "
f"progress: {progress}%, "
f"uploaded: {uploaded_bytes}/{file_size}"
)
else:
# 에러
error_data = response.json() if response.content else {}
error_message = error_data.get("error", {}).get(
"message", f"HTTP {response.status_code}"
)
logger.error(
f"[YOUTUBE_UPLOAD] 청크 업로드 실패 - error: {error_message}"
)
raise UploadError(
platform=self.platform.value,
detail=f"청크 업로드 실패: {error_message}",
)
raise UploadError(
platform=self.platform.value,
detail="업로드가 완료되지 않았습니다.",
)
async def get_upload_status(
self,
platform_video_id: str,
access_token: str,
) -> dict[str, Any]:
"""
업로드 상태 조회
Args:
platform_video_id: YouTube 영상 ID
access_token: OAuth 액세스 토큰
Returns:
dict: 업로드 상태 정보
"""
logger.info(f"[YOUTUBE_UPLOAD] 상태 조회 - video_id: {platform_video_id}")
headers = {"Authorization": f"Bearer {access_token}"}
params = {
"part": "status,processingDetails",
"id": platform_video_id,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
self.VIDEOS_URL,
headers=headers,
params=params,
)
if response.status_code == 200:
data = response.json()
items = data.get("items", [])
if items:
item = items[0]
status = item.get("status", {})
processing = item.get("processingDetails", {})
return {
"upload_status": status.get("uploadStatus"),
"privacy_status": status.get("privacyStatus"),
"processing_status": processing.get(
"processingStatus", "processing"
),
"processing_progress": processing.get(
"processingProgress", {}
),
}
return {"error": "영상을 찾을 수 없습니다."}
return {"error": f"상태 조회 실패: HTTP {response.status_code}"}
async def delete_video(
self,
platform_video_id: str,
access_token: str,
) -> bool:
"""
업로드된 영상 삭제
Args:
platform_video_id: YouTube 영상 ID
access_token: OAuth 액세스 토큰
Returns:
bool: 삭제 성공 여부
"""
logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 - video_id: {platform_video_id}")
headers = {"Authorization": f"Bearer {access_token}"}
params = {"id": platform_video_id}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.delete(
self.VIDEOS_URL,
headers=headers,
params=params,
)
if response.status_code == 204:
logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 성공 - video_id: {platform_video_id}")
return True
else:
logger.warning(
f"[YOUTUBE_UPLOAD] 영상 삭제 실패 - "
f"video_id: {platform_video_id}, status: {response.status_code}"
)
return False
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
"""
PrivacyStatus를 YouTube API 형식으로 변환
Args:
privacy_status: 공개 상태
Returns:
str: YouTube API 공개 상태
"""
mapping = {
PrivacyStatus.PUBLIC: "public",
PrivacyStatus.UNLISTED: "unlisted",
PrivacyStatus.PRIVATE: "private",
}
return mapping.get(privacy_status, "private")
def _get_category_id(self, metadata: UploadMetadata) -> str:
"""
카테고리 ID 추출
platform_options에서 category_id를 추출하거나 기본값 반환
Args:
metadata: 업로드 메타데이터
Returns:
str: YouTube 카테고리 ID
"""
if metadata.platform_options and "category_id" in metadata.platform_options:
return str(metadata.platform_options["category_id"])
# 기본값: "22" (People & Blogs)
return "22"
# 싱글톤 인스턴스
youtube_uploader = YouTubeUploader()

View File

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

View File

@ -0,0 +1,374 @@
"""
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}")

View File

@ -33,6 +33,7 @@ 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 테이블의 상태를 업데이트합니다.
@ -42,13 +43,20 @@ 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 suno_task_id:
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)
query_result = await session.execute(
select(Song)
.where(Song.suno_task_id == suno_task_id)
@ -56,6 +64,7 @@ async def _update_song_status(
.limit(1)
)
else:
# 기존 방식: task_id로 최신 레코드 조회 (비권장)
query_result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
@ -72,17 +81,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}, status: {status}")
logger.info(f"[Song] Status updated - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, status: {status}")
return True
else:
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {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}")
return False
except Exception as e:
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {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}")
return False

View File

@ -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}"
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}"
)
background_tasks.add_task(
download_and_upload_video_to_blob,
@ -576,6 +576,7 @@ 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(
@ -830,6 +831,7 @@ 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,
@ -857,3 +859,5 @@ async def get_videos(
status_code=500,
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
)

View File

@ -5,7 +5,7 @@ Video API Schemas
"""
from datetime import datetime
from typing import Any, Dict, Literal, Optional
from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict, Field
@ -141,6 +141,7 @@ class VideoListItem(BaseModel):
Example:
{
"video_id": 1,
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
@ -149,8 +150,11 @@ 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="생성 일시")

View File

@ -107,6 +107,7 @@ 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 테이블을 업데이트합니다.
@ -115,6 +116,7 @@ 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
@ -154,21 +156,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 테이블 업데이트
await _update_video_status(task_id, "completed", blob_url)
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
# 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}")
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")
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
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")
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
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")
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
finally:
# 임시 파일 삭제

134
config.py
View File

@ -452,6 +452,138 @@ 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()
@ -465,3 +597,5 @@ log_settings = LogSettings()
kakao_settings = KakaoSettings()
jwt_settings = JWTSettings()
recovery_settings = RecoverySettings()
social_oauth_settings = SocialOAuthSettings()
social_upload_settings = SocialUploadSettings()

View File

@ -0,0 +1,601 @@
{
"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)"
}
}
}

View File

@ -0,0 +1,58 @@
-- ===================================================================
-- 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
View File

@ -17,6 +17,8 @@ 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
@ -151,6 +153,55 @@ 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`: 업로드 실패
""",
},
]
@ -218,6 +269,7 @@ def custom_openapi():
"/crawling",
"/autocomplete",
"/search", # 숙박 검색 자동완성
"/social/oauth/youtube/callback", # OAuth 콜백 (플랫폼에서 직접 호출)
]
# 보안이 필요한 엔드포인트에 security 적용
@ -261,12 +313,18 @@ 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: