From 8c7893d9890b6a9039857b90938e309364f620b1 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 2 Feb 2026 16:42:38 +0900 Subject: [PATCH] =?UTF-8?q?youtube=20upload=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/archive/api/routers/v1/archive.py | 88 ++++++++++++++++++- app/core/exceptions.py | 14 +++ app/social/api/routers/v1/oauth.py | 56 ++++++++++-- app/social/api/routers/v1/upload.py | 35 +++++--- app/social/schemas.py | 18 ++-- app/social/services.py | 84 +++++++++++++++++- .../migration_add_social_upload.sql | 58 ++++++++++++ main.py | 4 + 8 files changed, 330 insertions(+), 27 deletions(-) create mode 100644 docs/database-schema/migration_add_social_upload.sql diff --git a/app/archive/api/routers/v1/archive.py b/app/archive/api/routers/v1/archive.py index 9ca7c3e..48eaefb 100644 --- a/app/archive/api/routers/v1/archive.py +++ b/app/archive/api/routers/v1/archive.py @@ -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": "삭제 요청 성공"}, diff --git a/app/core/exceptions.py b/app/core/exceptions.py index 63cba13..89a8d9d 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -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( diff --git a/app/social/api/routers/v1/oauth.py b/app/social/api/routers/v1/oauth.py index deb54a3..8ce1094 100644 --- a/app/social/api/routers/v1/oauth.py +++ b/app/social/api/routers/v1/oauth.py @@ -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] 소셜 연동 해제 - " diff --git a/app/social/api/routers/v1/upload.py b/app/social/api/routers/v1/upload.py index b964b28..79cf7bd 100644 --- a/app/social/api/routers/v1/upload.py +++ b/app/social/api/routers/v1/upload.py @@ -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="업로드 요청이 접수되었습니다.", ) diff --git a/app/social/schemas.py b/app/social/schemas.py index 66cf006..87e28a5 100644 --- a/app/social/schemas.py +++ b/app/social/schemas.py @@ -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 카테고리 }, diff --git a/app/social/services.py b/app/social/services.py index e640183..931e1a9 100644 --- a/app/social/services.py +++ b/app/social/services.py @@ -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 diff --git a/docs/database-schema/migration_add_social_upload.sql b/docs/database-schema/migration_add_social_upload.sql new file mode 100644 index 0000000..f092b2f --- /dev/null +++ b/docs/database-schema/migration_add_social_upload.sql @@ -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); diff --git a/main.py b/main.py index 02c8263..60fcf3f 100644 --- a/main.py +++ b/main.py @@ -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)