youtube upload 기능 작성 .

feature-youtube-upload
hbyang 2026-02-02 16:42:38 +09:00
parent ef203dc14d
commit 8c7893d989
8 changed files with 330 additions and 27 deletions

View File

@ -206,9 +206,94 @@ async def get_videos(
)
@router.delete(
"/videos/{video_id}",
summary="개별 영상 소프트 삭제",
description="""
## 개요
video_id에 해당하는 영상만 소프트 삭제합니다.
(is_deleted=True로 설정, 실제 데이터는 DB에 유지)
## 경로 파라미터
- **video_id**: 삭제할 영상의 ID (Video.id)
## 참고
- 본인이 소유한 프로젝트의 영상만 삭제할 있습니다.
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
- 프로젝트나 다른 관련 데이터(Song, Lyric ) 삭제되지 않습니다.
""",
responses={
200: {"description": "삭제 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
403: {"description": "삭제 권한 없음"},
404: {"description": "영상을 찾을 수 없음"},
500: {"description": "삭제 실패"},
},
)
async def delete_single_video(
video_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
"""video_id에 해당하는 개별 영상만 소프트 삭제합니다."""
logger.info(f"[delete_single_video] START - video_id: {video_id}, user: {current_user.user_uuid}")
try:
# Video 조회 (Project와 함께)
result = await session.execute(
select(Video, Project)
.join(Project, Video.project_id == Project.id)
.where(
Video.id == video_id,
Video.is_deleted == False,
)
)
row = result.one_or_none()
if row is None:
logger.warning(f"[delete_single_video] NOT FOUND - video_id: {video_id}")
raise HTTPException(
status_code=404,
detail="영상을 찾을 수 없습니다.",
)
video, project = row
# 소유권 검증
if project.user_uuid != current_user.user_uuid:
logger.warning(
f"[delete_single_video] FORBIDDEN - video_id: {video_id}, "
f"owner: {project.user_uuid}, requester: {current_user.user_uuid}"
)
raise HTTPException(
status_code=403,
detail="삭제 권한이 없습니다.",
)
# 소프트 삭제
video.is_deleted = True
await session.commit()
logger.info(f"[delete_single_video] SUCCESS - video_id: {video_id}")
return {
"success": True,
"message": "영상이 삭제되었습니다.",
"video_id": video_id,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[delete_single_video] EXCEPTION - video_id: {video_id}, error: {e}")
raise HTTPException(
status_code=500,
detail=f"삭제에 실패했습니다: {str(e)}",
)
@router.delete(
"/videos/delete/{task_id}",
summary="아카이브 영상 소프트 삭제",
summary="프로젝트 전체 소프트 삭제 (task_id 기준)",
description="""
## 개요
task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트 삭제합니다.
@ -229,6 +314,7 @@ task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트
- 본인이 소유한 프로젝트만 삭제할 있습니다.
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
- 백그라운드에서 비동기로 처리됩니다.
- **개별 영상만 삭제하려면 DELETE /archive/videos/{video_id} 사용하세요.**
""",
responses={
200: {"description": "삭제 요청 성공"},

View File

@ -288,6 +288,20 @@ def add_exception_handlers(app: FastAPI):
),
)
# SocialException 핸들러 추가
from app.social.exceptions import SocialException
@app.exception_handler(SocialException)
def social_exception_handler(request: Request, exc: SocialException) -> Response:
logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}")
return JSONResponse(
status_code=exc.status_code,
content={
"detail": exc.message,
"code": exc.code,
},
)
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
def internal_server_error_handler(request, exception):
return JSONResponse(

View File

@ -242,17 +242,63 @@ async def get_account_by_platform(
@router.delete(
"/{platform}/disconnect",
"/accounts/{account_id}",
response_model=MessageResponse,
summary="소셜 계정 연동 해제",
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,
@ -260,9 +306,9 @@ async def disconnect(
session: AsyncSession = Depends(get_session),
) -> MessageResponse:
"""
소셜 계정 연동 해제
소셜 계정 연동 해제 (platform 기준)
플랫폼 토큰을 폐기하고 연동해제합니다.
플랫폼으로 연동된 번째 계정해제합니다.
"""
logger.info(
f"[OAUTH_API] 소셜 연동 해제 - "

View File

@ -45,8 +45,14 @@ router = APIRouter(prefix="/upload", tags=["Social Upload"])
- 해당 플랫폼에 계정이 연동되어 있어야 합니다
- 영상이 completed 상태여야 합니다 (result_movie_url 필요)
## 지원 플랫폼
- **youtube**: YouTube
## 요청 필드
- **video_id**: 업로드할 영상 ID
- **social_account_id**: 업로드할 소셜 계정 ID (연동 계정 목록 조회 API에서 확인)
- **title**: 영상 제목 (최대 100)
- **description**: 영상 설명 (최대 5000)
- **tags**: 태그 목록
- **privacy_status**: 공개 상태 (public, unlisted, private)
- **scheduled_at**: 예약 게시 시간 (선택사항)
## 업로드 상태
업로드는 백그라운드에서 처리되며, 상태를 폴링하여 확인할 있습니다:
@ -72,7 +78,7 @@ async def upload_to_social(
f"[UPLOAD_API] 업로드 요청 - "
f"user_uuid: {current_user.user_uuid}, "
f"video_id: {body.video_id}, "
f"platform: {body.platform.value}"
f"social_account_id: {body.social_account_id}"
)
# 1. 영상 조회 및 검증
@ -92,19 +98,19 @@ async def upload_to_social(
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
)
# 2. 소셜 계정 조회
account = await social_account_service.get_account_by_platform(
# 2. 소셜 계정 조회 (social_account_id로 직접 조회, 소유권 검증 포함)
account = await social_account_service.get_account_by_id(
user_uuid=current_user.user_uuid,
platform=body.platform,
account_id=body.social_account_id,
session=session,
)
if not account:
logger.warning(
f"[UPLOAD_API] 연동 계정 없음 - "
f"user_uuid: {current_user.user_uuid}, platform: {body.platform.value}"
f"user_uuid: {current_user.user_uuid}, social_account_id: {body.social_account_id}"
)
raise SocialAccountNotFoundError(platform=body.platform.value)
raise SocialAccountNotFoundError()
# 3. 기존 업로드 확인 (동일 video + account 조합)
existing_result = await session.execute(
@ -125,7 +131,7 @@ async def upload_to_social(
return SocialUploadResponse(
success=True,
upload_id=existing_upload.id,
platform=body.platform.value,
platform=account.platform,
status=existing_upload.status,
message="이미 업로드가 진행 중입니다.",
)
@ -135,14 +141,17 @@ async def upload_to_social(
user_uuid=current_user.user_uuid,
video_id=body.video_id,
social_account_id=account.id,
platform=body.platform.value,
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,
platform_options={
**(body.platform_options or {}),
"scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None,
},
retry_count=0,
)
@ -152,7 +161,7 @@ async def upload_to_social(
logger.info(
f"[UPLOAD_API] 업로드 레코드 생성 - "
f"upload_id: {social_upload.id}, video_id: {body.video_id}"
f"upload_id: {social_upload.id}, video_id: {body.video_id}, platform: {account.platform}"
)
# 5. 백그라운드 태스크 등록
@ -161,7 +170,7 @@ async def upload_to_social(
return SocialUploadResponse(
success=True,
upload_id=social_upload.id,
platform=body.platform.value,
platform=account.platform,
status=social_upload.status,
message="업로드 요청이 접수되었습니다.",
)

View File

@ -132,14 +132,17 @@ class SocialUploadRequest(BaseModel):
"""소셜 업로드 요청"""
video_id: int = Field(..., description="업로드할 영상 ID")
platform: SocialPlatform = Field(..., description="업로드할 플랫폼")
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="태그 목록")
tags: Optional[list[str]] = Field(None, description="태그 목록 (쉼표로 구분된 문자열도 가능)")
privacy_status: PrivacyStatus = Field(
default=PrivacyStatus.PRIVATE, description="공개 상태"
default=PrivacyStatus.PRIVATE, description="공개 상태 (public, unlisted, private)"
)
scheduled_at: Optional[datetime] = Field(
None, description="예약 게시 시간 (없으면 즉시 게시)"
)
platform_options: Optional[dict[str, Any]] = Field(
None, description="플랫폼별 추가 옵션"
@ -149,11 +152,12 @@ class SocialUploadRequest(BaseModel):
json_schema_extra={
"example": {
"video_id": 123,
"platform": "youtube",
"title": "나의 첫 영상",
"social_account_id": 1,
"title": "도그앤조이 애견펜션 2026.02.02",
"description": "영상 설명입니다.",
"tags": ["여행", "vlog"],
"privacy_status": "private",
"tags": ["여행", "vlog", "애견펜션"],
"privacy_status": "public",
"scheduled_at": "2026-02-02T15:00:00",
"platform_options": {
"category_id": "22", # YouTube 카테고리
},

View File

@ -262,6 +262,88 @@ class SocialAccountService:
)
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,
@ -269,7 +351,7 @@ class SocialAccountService:
session: AsyncSession,
) -> bool:
"""
소셜 계정 연동 해제
소셜 계정 연동 해제 (platform 기준, deprecated)
Args:
user_uuid: 사용자 UUID

View File

@ -0,0 +1,58 @@
-- ===================================================================
-- social_upload 테이블 생성 마이그레이션
-- 소셜 미디어 업로드 기록을 저장하는 테이블
-- 생성일: 2026-02-02
-- ===================================================================
-- social_upload 테이블 생성
CREATE TABLE IF NOT EXISTS social_upload (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '고유 식별자',
-- 관계 필드
user_uuid VARCHAR(36) NOT NULL COMMENT '사용자 UUID (User.user_uuid 참조)',
video_id INT NOT NULL COMMENT 'Video 외래키',
social_account_id INT NOT NULL COMMENT 'SocialAccount 외래키',
-- 플랫폼 정보
platform VARCHAR(20) NOT NULL COMMENT '플랫폼 구분 (youtube, instagram, facebook, tiktok)',
-- 업로드 상태
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '업로드 상태 (pending, uploading, processing, completed, failed)',
upload_progress INT NOT NULL DEFAULT 0 COMMENT '업로드 진행률 (0-100)',
-- 플랫폼 결과
platform_video_id VARCHAR(100) NULL COMMENT '플랫폼에서 부여한 영상 ID',
platform_url VARCHAR(500) NULL COMMENT '플랫폼에서의 영상 URL',
-- 메타데이터
title VARCHAR(200) NOT NULL COMMENT '영상 제목',
description TEXT NULL COMMENT '영상 설명',
tags JSON NULL COMMENT '태그 목록 (JSON 배열)',
privacy_status VARCHAR(20) NOT NULL DEFAULT 'private' COMMENT '공개 상태 (public, unlisted, private)',
platform_options JSON NULL COMMENT '플랫폼별 추가 옵션 (JSON)',
-- 에러 정보
error_message TEXT NULL COMMENT '에러 메시지 (실패 시)',
retry_count INT NOT NULL DEFAULT 0 COMMENT '재시도 횟수',
-- 시간 정보
uploaded_at DATETIME NULL COMMENT '업로드 완료 시간',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시',
-- 외래키 제약조건
CONSTRAINT fk_social_upload_user FOREIGN KEY (user_uuid) REFERENCES user(user_uuid) ON DELETE CASCADE,
CONSTRAINT fk_social_upload_video FOREIGN KEY (video_id) REFERENCES video(id) ON DELETE CASCADE,
CONSTRAINT fk_social_upload_account FOREIGN KEY (social_account_id) REFERENCES social_account(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='소셜 미디어 업로드 기록 테이블';
-- 인덱스 생성
CREATE INDEX idx_social_upload_user_uuid ON social_upload(user_uuid);
CREATE INDEX idx_social_upload_video_id ON social_upload(video_id);
CREATE INDEX idx_social_upload_social_account_id ON social_upload(social_account_id);
CREATE INDEX idx_social_upload_platform ON social_upload(platform);
CREATE INDEX idx_social_upload_status ON social_upload(status);
CREATE INDEX idx_social_upload_created_at ON social_upload(created_at);
-- 유니크 인덱스 (동일 영상 + 동일 계정 조합은 하나만 존재)
CREATE UNIQUE INDEX uq_social_upload_video_platform ON social_upload(video_id, social_account_id);

View File

@ -313,6 +313,10 @@ def get_scalar_docs():
)
# 예외 핸들러 등록
from app.core.exceptions import add_exception_handlers
add_exception_handlers(app)
app.include_router(home_router)
app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
app.include_router(lyric_router)