Merge branch 'main' into creatomate
commit
e1386b891e
|
|
@ -37,7 +37,7 @@ router = APIRouter(prefix="/archive", tags=["Archive"])
|
||||||
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
- **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**: 전체 데이터 수
|
- **total**: 전체 데이터 수
|
||||||
- **page**: 현재 페이지
|
- **page**: 현재 페이지
|
||||||
- **page_size**: 페이지당 데이터 수
|
- **page_size**: 페이지당 데이터 수
|
||||||
|
|
@ -53,7 +53,7 @@ GET /archive/videos/?page=1&page_size=10
|
||||||
## 참고
|
## 참고
|
||||||
- **본인이 소유한 프로젝트의 영상만 반환됩니다.**
|
- **본인이 소유한 프로젝트의 영상만 반환됩니다.**
|
||||||
- status가 'completed'인 영상만 반환됩니다.
|
- status가 'completed'인 영상만 반환됩니다.
|
||||||
- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다.
|
- 재생성된 영상 포함 모든 영상이 반환됩니다.
|
||||||
- created_at 기준 내림차순 정렬됩니다.
|
- created_at 기준 내림차순 정렬됩니다.
|
||||||
""",
|
""",
|
||||||
response_model=PaginatedResponse[VideoListItem],
|
response_model=PaginatedResponse[VideoListItem],
|
||||||
|
|
@ -149,35 +149,21 @@ async def get_videos(
|
||||||
Project.is_deleted == False,
|
Project.is_deleted == False,
|
||||||
]
|
]
|
||||||
|
|
||||||
# 쿼리 1: 전체 개수 조회 (task_id 기준 고유 개수)
|
# 쿼리 1: 전체 개수 조회 (모든 영상)
|
||||||
count_query = (
|
count_query = (
|
||||||
select(func.count(func.distinct(Video.task_id)))
|
select(func.count(Video.id))
|
||||||
.join(Project, Video.project_id == Project.id)
|
.join(Project, Video.project_id == Project.id)
|
||||||
.where(*base_conditions)
|
.where(*base_conditions)
|
||||||
)
|
)
|
||||||
total_result = await session.execute(count_query)
|
total_result = await session.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
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 조회
|
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회 (모든 영상)
|
||||||
subquery = (
|
|
||||||
select(func.max(Video.id).label("max_id"))
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(*base_conditions)
|
|
||||||
.group_by(Video.task_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
# DEBUG: 서브쿼리 결과 확인
|
|
||||||
subquery_debug_result = await session.execute(select(subquery.c.max_id))
|
|
||||||
subquery_ids = [row[0] for row in subquery_debug_result.all()]
|
|
||||||
logger.debug(f"[get_videos] DEBUG - 서브쿼리 결과 (max_id 목록): {subquery_ids}")
|
|
||||||
|
|
||||||
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회
|
|
||||||
query = (
|
query = (
|
||||||
select(Video, Project)
|
select(Video, Project)
|
||||||
.join(Project, Video.project_id == Project.id)
|
.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())
|
.order_by(Video.created_at.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(pagination.page_size)
|
.limit(pagination.page_size)
|
||||||
|
|
@ -190,6 +176,7 @@ async def get_videos(
|
||||||
items = []
|
items = []
|
||||||
for video, project in rows:
|
for video, project in rows:
|
||||||
item = VideoListItem(
|
item = VideoListItem(
|
||||||
|
video_id=video.id,
|
||||||
store_name=project.store_name,
|
store_name=project.store_name,
|
||||||
region=project.region,
|
region=project.region,
|
||||||
task_id=video.task_id,
|
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(
|
@router.delete(
|
||||||
"/videos/delete/{task_id}",
|
"/videos/delete/{task_id}",
|
||||||
summary="아카이브 영상 소프트 삭제",
|
summary="프로젝트 전체 소프트 삭제 (task_id 기준)",
|
||||||
description="""
|
description="""
|
||||||
## 개요
|
## 개요
|
||||||
task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트 삭제합니다.
|
task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트 삭제합니다.
|
||||||
|
|
@ -242,6 +314,7 @@ task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트
|
||||||
- 본인이 소유한 프로젝트만 삭제할 수 있습니다.
|
- 본인이 소유한 프로젝트만 삭제할 수 있습니다.
|
||||||
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
|
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
|
||||||
- 백그라운드에서 비동기로 처리됩니다.
|
- 백그라운드에서 비동기로 처리됩니다.
|
||||||
|
- **개별 영상만 삭제하려면 DELETE /archive/videos/{video_id}를 사용하세요.**
|
||||||
""",
|
""",
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "삭제 요청 성공"},
|
200: {"description": "삭제 요청 성공"},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
def internal_server_error_handler(request, exception):
|
def internal_server_error_handler(request, exception):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
|
||||||
|
|
@ -292,21 +292,33 @@ async def generate_lyric(
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||||
|
|
||||||
# ========== Step 2: Project 테이블에 데이터 저장 ==========
|
# ========== Step 2: Project 조회 또는 생성 ==========
|
||||||
step2_start = time.perf_counter()
|
step2_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric] Step 2: Project 저장...")
|
logger.debug(f"[generate_lyric] Step 2: Project 조회 또는 생성...")
|
||||||
|
|
||||||
project = Project(
|
# 기존 Project가 있는지 확인 (재생성 시 재사용)
|
||||||
store_name=request_body.customer_name,
|
existing_project_result = await session.execute(
|
||||||
region=request_body.region,
|
select(Project).where(Project.task_id == task_id).limit(1)
|
||||||
task_id=task_id,
|
|
||||||
detail_region_info=request_body.detail_region_info,
|
|
||||||
language=request_body.language,
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
)
|
)
|
||||||
session.add(project)
|
project = existing_project_result.scalar_one_or_none()
|
||||||
await session.commit()
|
|
||||||
await session.refresh(project)
|
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,
|
||||||
|
task_id=task_id,
|
||||||
|
detail_region_info=request_body.detail_region_info,
|
||||||
|
language=request_body.language,
|
||||||
|
user_uuid=current_user.user_uuid,
|
||||||
|
)
|
||||||
|
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
|
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||||
logger.debug(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
|
logger.debug(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
|
||||||
|
|
@ -340,6 +352,7 @@ async def generate_lyric(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
prompt=lyric_prompt,
|
prompt=lyric_prompt,
|
||||||
lyric_input_data=lyric_input_data,
|
lyric_input_data=lyric_input_data,
|
||||||
|
lyric_id=lyric.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ async def _update_lyric_status(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
status: str,
|
status: str,
|
||||||
result: str | None = None,
|
result: str | None = None,
|
||||||
|
lyric_id: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Lyric 테이블의 상태를 업데이트합니다.
|
"""Lyric 테이블의 상태를 업데이트합니다.
|
||||||
|
|
||||||
|
|
@ -30,18 +31,26 @@ async def _update_lyric_status(
|
||||||
task_id: 프로젝트 task_id
|
task_id: 프로젝트 task_id
|
||||||
status: 변경할 상태 ("processing", "completed", "failed")
|
status: 변경할 상태 ("processing", "completed", "failed")
|
||||||
result: 가사 결과 또는 에러 메시지
|
result: 가사 결과 또는 에러 메시지
|
||||||
|
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: 업데이트 성공 여부
|
bool: 업데이트 성공 여부
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with BackgroundSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
query_result = await session.execute(
|
if lyric_id:
|
||||||
select(Lyric)
|
# lyric_id로 특정 레코드 조회 (재생성 시에도 정확한 레코드 업데이트)
|
||||||
.where(Lyric.task_id == task_id)
|
query_result = await session.execute(
|
||||||
.order_by(Lyric.created_at.desc())
|
select(Lyric).where(Lyric.id == lyric_id)
|
||||||
.limit(1)
|
)
|
||||||
)
|
else:
|
||||||
|
# 기존 방식: task_id로 최신 레코드 조회
|
||||||
|
query_result = await session.execute(
|
||||||
|
select(Lyric)
|
||||||
|
.where(Lyric.task_id == task_id)
|
||||||
|
.order_by(Lyric.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
lyric = query_result.scalar_one_or_none()
|
lyric = query_result.scalar_one_or_none()
|
||||||
|
|
||||||
if lyric:
|
if lyric:
|
||||||
|
|
@ -49,31 +58,33 @@ async def _update_lyric_status(
|
||||||
if result is not None:
|
if result is not None:
|
||||||
lyric.lyric_result = result
|
lyric.lyric_result = result
|
||||||
await session.commit()
|
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
|
return True
|
||||||
else:
|
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
|
return False
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
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
|
return False
|
||||||
except Exception as e:
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def generate_lyric_background(
|
async def generate_lyric_background(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
prompt: Prompt,
|
prompt: Prompt,
|
||||||
lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input
|
lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input
|
||||||
|
lyric_id: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
|
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: 프로젝트 task_id
|
task_id: 프로젝트 task_id
|
||||||
prompt: ChatGPT에 전달할 프롬프트
|
prompt: ChatGPT에 전달할 프롬프트
|
||||||
language: 가사 언어
|
lyric_input_data: 프롬프트 입력 데이터
|
||||||
|
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
@ -116,7 +127,7 @@ async def generate_lyric_background(
|
||||||
step3_start = time.perf_counter()
|
step3_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
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
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||||
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
|
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"[generate_lyric_background] ChatGPT ERROR - task_id: {task_id}, "
|
||||||
f"status: {e.status}, code: {e.error_code}, message: {e.error_message} ({elapsed:.1f}ms)"
|
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:
|
except SQLAlchemyError as e:
|
||||||
elapsed = (time.perf_counter() - task_start) * 1000
|
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)
|
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:
|
except Exception as e:
|
||||||
elapsed = (time.perf_counter() - task_start) * 1000
|
elapsed = (time.perf_counter() - task_start) * 1000
|
||||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")
|
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
"""
|
||||||
|
Social Media Integration Module
|
||||||
|
|
||||||
|
소셜 미디어 플랫폼 연동 및 영상 업로드 기능을 제공합니다.
|
||||||
|
|
||||||
|
지원 플랫폼:
|
||||||
|
- YouTube (구현됨)
|
||||||
|
- Instagram (추후 구현)
|
||||||
|
- Facebook (추후 구현)
|
||||||
|
- TikTok (추후 구현)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.social.constants import SocialPlatform, UploadStatus
|
||||||
|
|
||||||
|
__all__ = ["SocialPlatform", "UploadStatus"]
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Social API Module
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Social API Routers
|
||||||
|
"""
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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} 계정 연동이 해제되었습니다.",
|
||||||
|
)
|
||||||
|
|
@ -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="업로드가 취소되었습니다.",
|
||||||
|
)
|
||||||
|
|
@ -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",
|
||||||
|
# ]
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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")>"
|
||||||
|
)
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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": "작업이 완료되었습니다.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""
|
||||||
|
Social Worker Module
|
||||||
|
|
||||||
|
소셜 미디어 백그라운드 태스크 모듈입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.social.worker.upload_task import process_social_upload
|
||||||
|
|
||||||
|
__all__ = ["process_social_upload"]
|
||||||
|
|
@ -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}")
|
||||||
|
|
@ -33,6 +33,7 @@ async def _update_song_status(
|
||||||
song_url: str | None = None,
|
song_url: str | None = None,
|
||||||
suno_task_id: str | None = None,
|
suno_task_id: str | None = None,
|
||||||
duration: float | None = None,
|
duration: float | None = None,
|
||||||
|
song_id: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Song 테이블의 상태를 업데이트합니다.
|
"""Song 테이블의 상태를 업데이트합니다.
|
||||||
|
|
||||||
|
|
@ -42,13 +43,20 @@ async def _update_song_status(
|
||||||
song_url: 노래 URL
|
song_url: 노래 URL
|
||||||
suno_task_id: Suno task ID (선택)
|
suno_task_id: Suno task ID (선택)
|
||||||
duration: 노래 길이 (선택)
|
duration: 노래 길이 (선택)
|
||||||
|
song_id: 특정 Song 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: 업데이트 성공 여부
|
bool: 업데이트 성공 여부
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with BackgroundSessionLocal() as session:
|
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(
|
query_result = await session.execute(
|
||||||
select(Song)
|
select(Song)
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
.where(Song.suno_task_id == suno_task_id)
|
||||||
|
|
@ -56,6 +64,7 @@ async def _update_song_status(
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# 기존 방식: task_id로 최신 레코드 조회 (비권장)
|
||||||
query_result = await session.execute(
|
query_result = await session.execute(
|
||||||
select(Song)
|
select(Song)
|
||||||
.where(Song.task_id == task_id)
|
.where(Song.task_id == task_id)
|
||||||
|
|
@ -72,17 +81,17 @@ async def _update_song_status(
|
||||||
if duration is not None:
|
if duration is not None:
|
||||||
song.duration = duration
|
song.duration = duration
|
||||||
await session.commit()
|
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
|
return True
|
||||||
else:
|
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
|
return False
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
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
|
return False
|
||||||
except Exception as e:
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -568,7 +568,7 @@ async def get_video_status(
|
||||||
|
|
||||||
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
|
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
|
||||||
logger.info(
|
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(
|
background_tasks.add_task(
|
||||||
download_and_upload_video_to_blob,
|
download_and_upload_video_to_blob,
|
||||||
|
|
@ -576,6 +576,7 @@ async def get_video_status(
|
||||||
video_url=video_url,
|
video_url=video_url,
|
||||||
store_name=store_name,
|
store_name=store_name,
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
|
creatomate_render_id=creatomate_render_id,
|
||||||
)
|
)
|
||||||
elif video and video.status == "completed":
|
elif video and video.status == "completed":
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -830,6 +831,7 @@ async def get_videos(
|
||||||
project = projects_map.get(video.project_id)
|
project = projects_map.get(video.project_id)
|
||||||
|
|
||||||
item = VideoListItem(
|
item = VideoListItem(
|
||||||
|
video_id=video.id,
|
||||||
store_name=project.store_name if project else None,
|
store_name=project.store_name if project else None,
|
||||||
region=project.region if project else None,
|
region=project.region if project else None,
|
||||||
task_id=video.task_id,
|
task_id=video.task_id,
|
||||||
|
|
@ -857,3 +859,5 @@ async def get_videos(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
|
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Video API Schemas
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Literal, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
@ -141,6 +141,7 @@ class VideoListItem(BaseModel):
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
{
|
{
|
||||||
|
"video_id": 1,
|
||||||
"store_name": "스테이 머뭄",
|
"store_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"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="업체명")
|
store_name: Optional[str] = Field(None, description="업체명")
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ async def download_and_upload_video_to_blob(
|
||||||
video_url: str,
|
video_url: str,
|
||||||
store_name: str,
|
store_name: str,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
|
creatomate_render_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||||
|
|
||||||
|
|
@ -115,6 +116,7 @@ async def download_and_upload_video_to_blob(
|
||||||
video_url: 다운로드할 영상 URL
|
video_url: 다운로드할 영상 URL
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
store_name: 저장할 파일명에 사용할 업체명
|
||||||
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
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}")
|
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
||||||
temp_file_path: Path | None = None
|
temp_file_path: Path | None = None
|
||||||
|
|
@ -154,21 +156,21 @@ async def download_and_upload_video_to_blob(
|
||||||
blob_url = uploader.public_url
|
blob_url = uploader.public_url
|
||||||
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||||
|
|
||||||
# Video 테이블 업데이트
|
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
|
||||||
await _update_video_status(task_id, "completed", blob_url)
|
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}")
|
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:
|
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)
|
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:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
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:
|
finally:
|
||||||
# 임시 파일 삭제
|
# 임시 파일 삭제
|
||||||
|
|
|
||||||
134
config.py
134
config.py
|
|
@ -452,6 +452,138 @@ class LogSettings(BaseSettings):
|
||||||
return default_log_dir
|
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()
|
prj_settings = ProjectSettings()
|
||||||
apikey_settings = APIKeySettings()
|
apikey_settings = APIKeySettings()
|
||||||
db_settings = DatabaseSettings()
|
db_settings = DatabaseSettings()
|
||||||
|
|
@ -465,3 +597,5 @@ log_settings = LogSettings()
|
||||||
kakao_settings = KakaoSettings()
|
kakao_settings = KakaoSettings()
|
||||||
jwt_settings = JWTSettings()
|
jwt_settings = JWTSettings()
|
||||||
recovery_settings = RecoverySettings()
|
recovery_settings = RecoverySettings()
|
||||||
|
social_oauth_settings = SocialOAuthSettings()
|
||||||
|
social_upload_settings = SocialUploadSettings()
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
58
main.py
|
|
@ -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.lyric.api.routers.v1.lyric import router as lyric_router
|
||||||
from app.song.api.routers.v1.song import router as song_router
|
from app.song.api.routers.v1.song import router as song_router
|
||||||
from app.video.api.routers.v1.video import router as video_router
|
from app.video.api.routers.v1.video import router as video_router
|
||||||
|
from app.social.api.routers.v1.oauth import router as social_oauth_router
|
||||||
|
from app.social.api.routers.v1.upload import router as social_upload_router
|
||||||
from app.utils.cors import CustomCORSMiddleware
|
from app.utils.cors import CustomCORSMiddleware
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
||||||
|
|
@ -151,6 +153,55 @@ tags_metadata = [
|
||||||
- created_at 기준 내림차순 정렬됩니다.
|
- created_at 기준 내림차순 정렬됩니다.
|
||||||
- 삭제는 소프트 삭제(is_deleted=True) 방식으로 처리되며, 데이터 복구가 가능합니다.
|
- 삭제는 소프트 삭제(is_deleted=True) 방식으로 처리되며, 데이터 복구가 가능합니다.
|
||||||
- 삭제 대상: Video, SongTimestamp, Song, Lyric, Image, Project
|
- 삭제 대상: 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",
|
"/crawling",
|
||||||
"/autocomplete",
|
"/autocomplete",
|
||||||
"/search", # 숙박 검색 자동완성
|
"/search", # 숙박 검색 자동완성
|
||||||
|
"/social/oauth/youtube/callback", # OAuth 콜백 (플랫폼에서 직접 호출)
|
||||||
]
|
]
|
||||||
|
|
||||||
# 보안이 필요한 엔드포인트에 security 적용
|
# 보안이 필요한 엔드포인트에 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(home_router)
|
||||||
app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
|
app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
|
||||||
app.include_router(lyric_router)
|
app.include_router(lyric_router)
|
||||||
app.include_router(song_router)
|
app.include_router(song_router)
|
||||||
app.include_router(video_router)
|
app.include_router(video_router)
|
||||||
app.include_router(archive_router) # Archive API 라우터 추가
|
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 모드에서만 테스트 라우터 등록
|
# DEBUG 모드에서만 테스트 라우터 등록
|
||||||
if prj_settings.DEBUG:
|
if prj_settings.DEBUG:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue