feat: 좋아요·댓글·SNS 업로드를 콘텐츠 종류 공용으로 통합

This commit is contained in:
김성경 2026-08-11 11:03:03 +09:00
parent ed1ba71bfe
commit fb8febe462
7 changed files with 318 additions and 109 deletions

View File

@ -9,7 +9,9 @@ Comment API Router
- DELETE /comment/{comment_id}: 본인 댓글 소프트 삭제 (로그인 필수) - DELETE /comment/{comment_id}: 본인 댓글 소프트 삭제 (로그인 필수)
""" """
from fastapi import APIRouter, Depends from typing import Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.comment.schemas.comment_schema import ( from app.comment.schemas.comment_schema import (
@ -60,12 +62,16 @@ router = APIRouter(prefix="/comment", tags=["Comment"])
async def post_comment( async def post_comment(
video_id: int, video_id: int,
body: CommentCreateRequest, body: CommentCreateRequest,
type: Literal["video", "ssul"] = Query(
default="video",
description="콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 반드시 함께 보낼 것",
),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> CommentCreateResponse: ) -> CommentCreateResponse:
logger.info( logger.info(
f"[post_comment] START - video_id: {video_id}, user: {current_user.user_uuid}, " f"[post_comment] START - type: {type}, id: {video_id}, "
f"parent_id: {body.parent_id}" f"user: {current_user.user_uuid}, parent_id: {body.parent_id}"
) )
comment = await create_comment( comment = await create_comment(
session=session, session=session,
@ -74,6 +80,7 @@ async def post_comment(
nickname=body.nickname, nickname=body.nickname,
content=body.content, content=body.content,
parent_id=body.parent_id, parent_id=body.parent_id,
content_type=type,
) )
logger.info(f"[post_comment] SUCCESS - comment_id: {comment.id}") logger.info(f"[post_comment] SUCCESS - comment_id: {comment.id}")
return CommentCreateResponse( return CommentCreateResponse(
@ -112,12 +119,16 @@ async def post_comment(
) )
async def get_comments( async def get_comments(
video_id: int, video_id: int,
type: Literal["video", "ssul"] = Query(
default="video",
description="콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 반드시 함께 보낼 것",
),
current_user: User | None = Depends(get_current_user_optional), current_user: User | None = Depends(get_current_user_optional),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
pagination: PaginationParams = Depends(get_pagination_params), pagination: PaginationParams = Depends(get_pagination_params),
) -> PaginatedResponse[CommentItem]: ) -> PaginatedResponse[CommentItem]:
logger.info( logger.info(
f"[get_comments] START - video_id: {video_id}, " f"[get_comments] START - type: {type}, id: {video_id}, "
f"page: {pagination.page}, page_size: {pagination.page_size}" f"page: {pagination.page}, page_size: {pagination.page_size}"
) )
current_user_uuid = current_user.user_uuid if current_user else None current_user_uuid = current_user.user_uuid if current_user else None
@ -127,6 +138,7 @@ async def get_comments(
page=pagination.page, page=pagination.page,
page_size=pagination.page_size, page_size=pagination.page_size,
current_user_uuid=current_user_uuid, current_user_uuid=current_user_uuid,
content_type=type,
) )
logger.info(f"[get_comments] SUCCESS - total: {result.total}, items: {len(result.items)}") logger.info(f"[get_comments] SUCCESS - total: {result.total}, items: {len(result.items)}")
return result return result

View File

@ -1,5 +1,5 @@
from collections import defaultdict from collections import defaultdict
from typing import List, Optional from typing import List, Literal, Optional
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import exists, select from sqlalchemy import exists, select
@ -7,16 +7,54 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.comment.models import Comment from app.comment.models import Comment
from app.comment.schemas.comment_schema import CommentItem, ReplyItem from app.comment.schemas.comment_schema import CommentItem, ReplyItem
from app.ssulbox.models import SsulContent
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
from app.video.models import Video from app.video.models import Video
ContentType = Literal["video", "ssul"]
def _target_col(content_type: ContentType):
"""댓글이 달린 대상을 가리키는 컬럼.
`comment` 는 ADO2 영상과 썰박스를 함께 담고 `video_id` / `content_id` 중
정확히 하나만 채운다(CHECK 로 강제). 종류는 어느 컬럼이 채워졌는지가 결정한다.
"""
return Comment.video_id if content_type == "video" else Comment.content_id
async def _ensure_target_exists(
session: AsyncSession,
content_type: ContentType,
target_id: int,
) -> None:
"""댓글 대상(영상 또는 썰박스 콘텐츠)이 존재·공개 상태인지 확인.
id 가 종류별 독립 시퀀스라, 반대쪽 테이블에 같은 id 가 있어도 잡으면 안 된다.
"""
if content_type == "ssul":
q = select(SsulContent.id).where(
SsulContent.id == target_id,
SsulContent.status == "done",
SsulContent.is_deleted.is_(False),
)
else:
q = select(Video.id).where(
Video.id == target_id,
Video.status == "completed",
Video.is_deleted.is_(False),
)
if (await session.execute(q)).scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="콘텐츠를 찾을 수 없습니다.")
async def _validate_parent( async def _validate_parent(
session: AsyncSession, session: AsyncSession,
parent_id: int, parent_id: int,
video_id: int, content_type: ContentType,
target_id: int,
) -> None: ) -> None:
"""2-depth 제한 + 동일 video 검증.""" """2-depth 제한 + 동일 대상 검증."""
result = await session.execute( result = await session.execute(
select(Comment).where( select(Comment).where(
Comment.id == parent_id, Comment.id == parent_id,
@ -27,8 +65,11 @@ async def _validate_parent(
if parent is None: if parent is None:
raise HTTPException(status_code=400, detail="부모 댓글을 찾을 수 없습니다.") raise HTTPException(status_code=400, detail="부모 댓글을 찾을 수 없습니다.")
if parent.video_id != video_id: # 부모가 같은 종류의 같은 대상에 달렸는지 본다. video_id 만 비교하면
raise HTTPException(status_code=400, detail="다른 영상의 댓글에는 대댓글을 달 수 없습니다.") # 썰박스 댓글(video_id=NULL)에 ADO2 대댓글이 달리는 교차를 못 막는다.
parent_target = parent.video_id if content_type == "video" else parent.content_id
if parent_target != target_id:
raise HTTPException(status_code=400, detail="다른 콘텐츠의 댓글에는 대댓글을 달 수 없습니다.")
if parent.parent_id is not None: if parent.parent_id is not None:
raise HTTPException(status_code=400, detail="대댓글에는 대댓글을 달 수 없습니다. (최대 2-depth)") raise HTTPException(status_code=400, detail="대댓글에는 대댓글을 달 수 없습니다. (최대 2-depth)")
@ -73,24 +114,19 @@ async def create_comment(
nickname: str, nickname: str,
content: str, content: str,
parent_id: Optional[int], parent_id: Optional[int],
content_type: ContentType = "video",
) -> Comment: ) -> Comment:
# Video 존재 확인 # 대상 존재 확인
video_result = await session.execute( await _ensure_target_exists(session, content_type, video_id)
select(Video).where(
Video.id == video_id,
Video.status == "completed",
Video.is_deleted == False, # noqa: E712
)
)
if video_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
# parent_id 검증 # parent_id 검증
if parent_id is not None: if parent_id is not None:
await _validate_parent(session, parent_id, video_id) await _validate_parent(session, parent_id, content_type, video_id)
comment = Comment( comment = Comment(
video_id=video_id, # 종류에 따라 둘 중 하나만 채운다 (CHECK ck_comment_one_target 이 강제)
video_id=video_id if content_type == "video" else None,
content_id=video_id if content_type == "ssul" else None,
user_uuid=user_uuid, user_uuid=user_uuid,
nickname=nickname, nickname=nickname,
parent_id=parent_id, parent_id=parent_id,
@ -108,6 +144,7 @@ async def list_comments(
page: int, page: int,
page_size: int, page_size: int,
current_user_uuid: Optional[str], current_user_uuid: Optional[str],
content_type: ContentType = "video",
) -> PaginatedResponse[CommentItem]: ) -> PaginatedResponse[CommentItem]:
offset = (page - 1) * page_size offset = (page - 1) * page_size
@ -121,9 +158,10 @@ async def list_comments(
.correlate(Comment) .correlate(Comment)
) )
# 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것 # 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것.
# 종류에 맞는 대상 컬럼으로 걸러야 같은 id 의 반대 종류 댓글이 섞이지 않는다.
parent_where = [ parent_where = [
Comment.video_id == video_id, _target_col(content_type) == video_id,
Comment.parent_id.is_(None), Comment.parent_id.is_(None),
(Comment.is_deleted == False) | has_live_reply, # noqa: E712 (Comment.is_deleted == False) | has_live_reply, # noqa: E712
] ]

View File

@ -7,7 +7,17 @@ Social Media Models
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func from sqlalchemy import (
BigInteger,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.mysql import JSON from sqlalchemy.dialects.mysql import JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -53,6 +63,13 @@ class SocialUpload(Base):
__tablename__ = "social_upload" __tablename__ = "social_upload"
__table_args__ = ( __table_args__ = (
# ADO2 영상과 썰박스 콘텐츠를 함께 담는다(2026-07-30 병합,
# docs/manual_ddl/2026-07-30-social-upload-merge.sql).
# 대상은 video_id / content_id 중 **정확히 하나**만 채워진다.
CheckConstraint(
"(video_id IS NULL) <> (content_id IS NULL)",
name="ck_social_upload_one_target",
),
Index("idx_social_upload_user_uuid", "user_uuid"), Index("idx_social_upload_user_uuid", "user_uuid"),
Index("idx_social_upload_video_id", "video_id"), Index("idx_social_upload_video_id", "video_id"),
Index("idx_social_upload_social_account_id", "social_account_id"), Index("idx_social_upload_social_account_id", "social_account_id"),
@ -61,8 +78,14 @@ class SocialUpload(Base):
Index("idx_social_upload_created_at", "created_at"), Index("idx_social_upload_created_at", "created_at"),
# 동일 영상+채널 조합 조회용 인덱스 (유니크 아님 - 여러 번 업로드 가능) # 동일 영상+채널 조합 조회용 인덱스 (유니크 아님 - 여러 번 업로드 가능)
Index("idx_social_upload_video_account", "video_id", "social_account_id"), Index("idx_social_upload_video_account", "video_id", "social_account_id"),
# 순번 조회용 인덱스 # 순번 조회용 인덱스 (종류별. content 쪽은 선행 컬럼이라 FK 인덱스도 겸한다)
Index("idx_social_upload_seq", "video_id", "social_account_id", "upload_seq"), Index("idx_social_upload_seq", "video_id", "social_account_id", "upload_seq"),
Index(
"idx_social_upload_content_seq",
"content_id",
"social_account_id",
"upload_seq",
),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -91,11 +114,19 @@ class SocialUpload(Base):
comment="사용자 UUID (User.user_uuid 참조)", comment="사용자 UUID (User.user_uuid 참조)",
) )
video_id: Mapped[int] = mapped_column( # 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_social_upload_one_target).
video_id: Mapped[Optional[int]] = mapped_column(
Integer, Integer,
ForeignKey("video.id", ondelete="CASCADE"), ForeignKey("video.id", ondelete="CASCADE"),
nullable=False, nullable=True,
comment="Video 외래키", comment="ADO2 영상 id (썰박스 업로드면 NULL)",
)
content_id: Mapped[Optional[int]] = mapped_column(
# ssul_content.id 는 BIGINT 다. INT 로 두면 FK 타입 불일치(errno 3780).
BigInteger,
ForeignKey("ssul_content.id", ondelete="CASCADE"),
nullable=True,
comment="썰박스 콘텐츠 id (ADO2 업로드면 NULL)",
) )
social_account_id: Mapped[int] = mapped_column( social_account_id: Mapped[int] = mapped_column(
@ -242,8 +273,10 @@ class SocialUpload(Base):
# ========================================================================== # ==========================================================================
# Relationships # Relationships
# ========================================================================== # ==========================================================================
video: Mapped["Video"] = relationship( # 썰박스 업로드면 None 이다. 접근하는 쪽에서 반드시 방어할 것.
video: Mapped[Optional["Video"]] = relationship(
"Video", "Video",
foreign_keys=[video_id],
lazy="selectin", lazy="selectin",
) )

View File

@ -3,7 +3,7 @@
""" """
from datetime import datetime from datetime import datetime
from typing import Any, Optional from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@ -13,7 +13,14 @@ from app.social.constants import PrivacyStatus, UploadStatus
class SocialUploadRequest(BaseModel): class SocialUploadRequest(BaseModel):
"""소셜 업로드 요청""" """소셜 업로드 요청"""
video_id: int = Field(..., description="업로드할 영상 ID") # ⚠️ video_id 는 content_type 안에서만 유일하다 — video.id 와 ssul_content.id 는
# 각각 1부터 시작하는 독립 시퀀스라 값이 겹친다. content_type 없이 썰박스 id 를
# 보내면 **id 가 겹치는 남의 ADO2 영상이 업로드된다.**
content_type: Literal["video", "ssul"] = Field(
default="video",
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스)",
)
video_id: int = Field(..., description="업로드할 콘텐츠 ID (content_type 안에서만 유일)")
social_account_id: int = Field(..., description="업로드할 소셜 계정 ID (연동 계정 목록의 id)") social_account_id: int = Field(..., description="업로드할 소셜 계정 ID (연동 계정 목록의 id)")
title: str = Field(..., min_length=1, max_length=100, description="영상 제목") title: str = Field(..., min_length=1, max_length=100, description="영상 제목")
description: Optional[str] = Field( description: Optional[str] = Field(
@ -77,7 +84,9 @@ class SocialUploadStatusResponse(BaseModel):
"""업로드 상태 조회 응답""" """업로드 상태 조회 응답"""
upload_id: int = Field(..., description="업로드 작업 ID") upload_id: int = Field(..., description="업로드 작업 ID")
video_id: int = Field(..., description="영상 ID") # 썰박스 업로드면 video_id 가 None 이고 content_id 가 채워진다 (정확히 하나만)
video_id: Optional[int] = Field(None, description="ADO2 영상 ID (썰박스면 None)")
content_id: Optional[int] = Field(None, description="썰박스 콘텐츠 ID (ADO2 면 None)")
social_account_id: int = Field(..., description="소셜 계정 ID") social_account_id: int = Field(..., description="소셜 계정 ID")
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)") upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
platform: str = Field(..., description="플랫폼명") platform: str = Field(..., description="플랫폼명")
@ -119,7 +128,9 @@ class SocialUploadHistoryItem(BaseModel):
"""업로드 이력 아이템""" """업로드 이력 아이템"""
upload_id: int = Field(..., description="업로드 작업 ID") upload_id: int = Field(..., description="업로드 작업 ID")
video_id: int = Field(..., description="영상 ID") # 썰박스 업로드면 video_id 가 None 이고 content_id 가 채워진다 (정확히 하나만)
video_id: Optional[int] = Field(None, description="ADO2 영상 ID (썰박스면 None)")
content_id: Optional[int] = Field(None, description="썰박스 콘텐츠 ID (ADO2 면 None)")
social_account_id: int = Field(..., description="소셜 계정 ID") social_account_id: int = Field(..., description="소셜 계정 ID")
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)") upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
platform: str = Field(..., description="플랫폼명") platform: str = Field(..., description="플랫폼명")

View File

@ -28,6 +28,8 @@ from app.social.schemas import (
from app.social.services.account_service import SocialAccountService from app.social.services.account_service import SocialAccountService
from app.social.worker.upload_task import process_social_upload from app.social.worker.upload_task import process_social_upload
from app.user.models import User from app.user.models import User
from app.home.models import Project
from app.ssulbox.models import SsulContent
from app.video.models import Video from app.video.models import Video
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -55,26 +57,61 @@ class SocialUploadService:
logger.info( logger.info(
f"[UPLOAD_SERVICE] 업로드 요청 - " f"[UPLOAD_SERVICE] 업로드 요청 - "
f"user_uuid: {current_user.user_uuid}, " f"user_uuid: {current_user.user_uuid}, "
f"video_id: {body.video_id}, " f"type: {body.content_type}, id: {body.video_id}, "
f"social_account_id: {body.social_account_id}" f"social_account_id: {body.social_account_id}"
) )
# 1. 영상 조회 및 검증 # 1. 대상 조회 및 검증.
video_result = await session.execute( # video.id 와 ssul_content.id 는 값이 겹치는 독립 시퀀스라 content_type 으로
select(Video).where(Video.id == body.video_id) # 정확히 한 테이블만 봐야 한다 — 아니면 남의 다른 콘텐츠가 업로드된다.
) # `target_col` 은 이후 중복 확인·채번에서도 같은 컬럼을 쓰기 위한 것이다.
video = video_result.scalar_one_or_none() # ⚠️ 소유자 필터가 필수다. 대상 id 는 클라이언트가 보내는 값이라, 안 거르면
# **남의 콘텐츠를 자기 SNS 채널에 업로드**할 수 있다(계정 소유권만 검증하고
# 콘텐츠 소유권을 안 보면 IDOR 이 된다).
if body.content_type == "ssul":
target_col = SocialUpload.content_id
content = (
await session.execute(
select(SsulContent).where(
SsulContent.id == body.video_id,
SsulContent.user_uuid == current_user.user_uuid,
SsulContent.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if not content:
logger.warning(f"[UPLOAD_SERVICE] 썰박스 콘텐츠 없음 - id: {body.video_id}")
raise VideoNotFoundError(video_id=body.video_id)
if content.status != "done" or not content.video_url:
logger.warning(f"[UPLOAD_SERVICE] 썰박스 영상 미완성 - id: {body.video_id}")
raise VideoNotFoundError(
video_id=body.video_id,
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
)
else:
target_col = SocialUpload.video_id
# video 에는 user_uuid 가 없다 — 소유권은 project 에 있으므로 조인한다.
video = (
await session.execute(
select(Video)
.join(Project, Video.project_id == Project.id)
.where(
Video.id == body.video_id,
Project.user_uuid == current_user.user_uuid,
)
)
).scalar_one_or_none()
if not video: if not video:
logger.warning(f"[UPLOAD_SERVICE] 영상 없음 - video_id: {body.video_id}") logger.warning(f"[UPLOAD_SERVICE] 영상 없음 - video_id: {body.video_id}")
raise VideoNotFoundError(video_id=body.video_id) raise VideoNotFoundError(video_id=body.video_id)
if not video.result_movie_url: if not video.result_movie_url:
logger.warning(f"[UPLOAD_SERVICE] 영상 URL 없음 - video_id: {body.video_id}") logger.warning(f"[UPLOAD_SERVICE] 영상 URL 없음 - video_id: {body.video_id}")
raise VideoNotFoundError( raise VideoNotFoundError(
video_id=body.video_id, video_id=body.video_id,
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.", detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
) )
# 2. 소셜 계정 조회 및 소유권 검증 # 2. 소셜 계정 조회 및 소유권 검증
account = await self._account_service.get_account_by_id( account = await self._account_service.get_account_by_id(
@ -96,7 +133,7 @@ class SocialUploadService:
# 3-1. 진행 중인 업로드 확인 (즉시 pending 또는 uploading) # 3-1. 진행 중인 업로드 확인 (즉시 pending 또는 uploading)
in_progress_result = await session.execute( in_progress_result = await session.execute(
select(SocialUpload).where( select(SocialUpload).where(
SocialUpload.video_id == body.video_id, target_col == body.video_id,
SocialUpload.social_account_id == account.id, SocialUpload.social_account_id == account.id,
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]), SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
or_( or_(
@ -122,7 +159,7 @@ class SocialUploadService:
# 3-2. 미래 예약 업로드 확인 # 3-2. 미래 예약 업로드 확인
scheduled_result = await session.execute( scheduled_result = await session.execute(
select(SocialUpload).where( select(SocialUpload).where(
SocialUpload.video_id == body.video_id, target_col == body.video_id,
SocialUpload.social_account_id == account.id, SocialUpload.social_account_id == account.id,
SocialUpload.status == UploadStatus.PENDING.value, SocialUpload.status == UploadStatus.PENDING.value,
SocialUpload.scheduled_at.isnot(None), SocialUpload.scheduled_at.isnot(None),
@ -148,7 +185,7 @@ class SocialUploadService:
# 4. 업로드 순번 계산 # 4. 업로드 순번 계산
max_seq_result = await session.execute( max_seq_result = await session.execute(
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where( select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
SocialUpload.video_id == body.video_id, target_col == body.video_id,
SocialUpload.social_account_id == account.id, SocialUpload.social_account_id == account.id,
) )
) )
@ -157,7 +194,9 @@ class SocialUploadService:
# 5. 새 업로드 레코드 생성 # 5. 새 업로드 레코드 생성
social_upload = SocialUpload( social_upload = SocialUpload(
user_uuid=current_user.user_uuid, user_uuid=current_user.user_uuid,
video_id=body.video_id, # 종류에 따라 둘 중 하나만 채운다 (CHECK ck_social_upload_one_target 이 강제)
video_id=body.video_id if body.content_type == "video" else None,
content_id=body.video_id if body.content_type == "ssul" else None,
social_account_id=account.id, social_account_id=account.id,
upload_seq=next_seq, upload_seq=next_seq,
platform=account.platform, platform=account.platform,
@ -225,6 +264,7 @@ class SocialUploadService:
return SocialUploadStatusResponse( return SocialUploadStatusResponse(
upload_id=upload.id, upload_id=upload.id,
video_id=upload.video_id, video_id=upload.video_id,
content_id=upload.content_id,
social_account_id=upload.social_account_id, social_account_id=upload.social_account_id,
upload_seq=upload.upload_seq, upload_seq=upload.upload_seq,
platform=upload.platform, platform=upload.platform,
@ -318,6 +358,7 @@ class SocialUploadService:
SocialUploadHistoryItem( SocialUploadHistoryItem(
upload_id=upload.id, upload_id=upload.id,
video_id=upload.video_id, video_id=upload.video_id,
content_id=upload.content_id,
social_account_id=upload.social_account_id, social_account_id=upload.social_account_id,
upload_seq=upload.upload_seq, upload_seq=upload.upload_seq,
platform=upload.platform, platform=upload.platform,

View File

@ -5,8 +5,6 @@ Social Upload Background Task
""" """
import logging import logging
import os
import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -21,11 +19,12 @@ from config import social_upload_settings
from app.dashboard.tasks import insert_dashboard from app.dashboard.tasks import insert_dashboard
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.social.constants import SocialPlatform, UploadStatus from app.social.constants import SocialPlatform, UploadStatus
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError from app.social.exceptions import TokenExpiredError, UploadQuotaExceededError
from app.social.models import SocialUpload from app.social.models import SocialUpload
from app.social.services import social_account_service from app.social.services import social_account_service
from app.social.uploader import get_uploader from app.social.uploader import get_uploader
from app.social.uploader.base import UploadMetadata from app.social.uploader.base import UploadMetadata
from app.ssulbox.models import SsulContent
from app.user.models import SocialAccount from app.user.models import SocialAccount
from app.video.models import Video from app.video.models import Video
@ -169,16 +168,27 @@ async def process_social_upload(upload_id: int) -> None:
logger.error(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}") logger.error(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}")
return return
# 2. Video 정보 조회 # 2. 대상 영상 조회.
video_result = await session.execute( # 병합 스키마: video_id / content_id 중 정확히 하나만 채워져 있다(CHECK).
select(Video).where(Video.id == upload.video_id) # 채워진 쪽이 곧 종류다 — 썰박스면 ssul_content.video_url 을 쓴다.
) if upload.content_id is not None:
video = video_result.scalar_one_or_none() content_result = await session.execute(
select(SsulContent).where(SsulContent.id == upload.content_id)
)
content = content_result.scalar_one_or_none()
source_url = content.video_url if content else None
else:
video_result = await session.execute(
select(Video).where(Video.id == upload.video_id)
)
video = video_result.scalar_one_or_none()
source_url = video.result_movie_url if video else None
if not video or not video.result_movie_url: if not source_url:
logger.error( logger.error(
f"[SOCIAL_UPLOAD] 영상 없음 또는 URL 없음 - " f"[SOCIAL_UPLOAD] 영상 없음 또는 URL 없음 - "
f"upload_id: {upload_id}, video_id: {upload.video_id}" f"upload_id: {upload_id}, video_id: {upload.video_id}, "
f"content_id: {upload.content_id}"
) )
await _update_upload_status( await _update_upload_status(
upload_id=upload_id, upload_id=upload_id,
@ -206,7 +216,7 @@ async def process_social_upload(upload_id: int) -> None:
return return
# 필요한 정보 저장 # 필요한 정보 저장
video_url = video.result_movie_url video_url = source_url
platform = SocialPlatform(upload.platform) platform = SocialPlatform(upload.platform)
upload_title = upload.title upload_title = upload.title
upload_description = upload.description upload_description = upload.description
@ -347,7 +357,7 @@ async def process_social_upload(upload_id: int) -> None:
f"upload_id: {upload_id}, error: {result.error_message}" f"upload_id: {upload_id}, error: {result.error_message}"
) )
except UploadQuotaExceededError as e: except UploadQuotaExceededError:
logger.error(f"[SOCIAL_UPLOAD] API 할당량 초과 - upload_id: {upload_id}") logger.error(f"[SOCIAL_UPLOAD] API 할당량 초과 - upload_id: {upload_id}")
await _update_upload_status( await _update_upload_status(
upload_id=upload_id, upload_id=upload_id,

View File

@ -42,6 +42,7 @@ from app.database.like_cache import (
from app.credit.exceptions import InsufficientCreditError from app.credit.exceptions import InsufficientCreditError
from app.credit.services.credit_service import deduct_credit_for_job from app.credit.services.credit_service import deduct_credit_for_job
from app.ssulbox.constants import JOB_TYPE_VIDEO as CREDIT_JOB_TYPE_VIDEO from app.ssulbox.constants import JOB_TYPE_VIDEO as CREDIT_JOB_TYPE_VIDEO
from app.ssulbox.models import SsulContent
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.video.models import Video, VideoReaction from app.video.models import Video, VideoReaction
from app.video.services import unified_list from app.video.services import unified_list
@ -55,7 +56,10 @@ from app.video.schemas.video_schema import (
VideoRenderData, VideoRenderData,
VideoThumbnailItem, VideoThumbnailItem,
) )
from app.video.worker.video_task import download_and_upload_video_to_blob from app.video.worker.video_task import (
_fail_and_refund,
download_and_upload_video_to_blob,
)
from config import creatomate_settings from config import creatomate_settings
@ -181,10 +185,17 @@ async def generate_video(
# ===== 순차 쿼리 실행: Project, MarketingIntel, Lyric, Song, Image ===== # ===== 순차 쿼리 실행: Project, MarketingIntel, Lyric, Song, Image =====
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음 # Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
# Project 조회 # Project 조회 (본인 소유만).
# ⚠️ task_id 는 경로 파라미터라 남의 값을 넣을 수 있다. 소유자를 안 거르면
# 남의 프로젝트로 영상을 만들 수 있고, 더 나쁘게는 크레딧 원장에
# job_ref=피해자 task_id 로 차감이 기록돼 **피해자의 정상 생성이
# "이미 차감됨"으로 처리**된다(멱등 키 오염).
project_result = await session.execute( project_result = await session.execute(
select(Project) select(Project)
.where(Project.task_id == task_id) .where(
Project.task_id == task_id,
Project.user_uuid == current_user.user_uuid,
)
.order_by(Project.created_at.desc()) .order_by(Project.created_at.desc())
.limit(1) .limit(1)
) )
@ -521,17 +532,13 @@ async def generate_video(
) )
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
# 외부 API 실패 시 Video 상태를 failed로 업데이트 # 외부 API 실패 시 Video 상태를 failed로 갱신하고, 선차감한 크레딧을 환불한다.
from app.database.session import AsyncSessionLocal # 크레딧은 1단계에서 이미 차감됐으므로 여기서 돌려주지 않으면 그대로 소멸된다.
await _fail_and_refund(
async with AsyncSessionLocal() as update_session: task_id,
video_result = await update_session.execute( user_uuid=current_user.user_uuid,
select(Video).where(Video.id == video_id) reason="영상 생성 실패 환불 (Creatomate 요청 오류)",
) )
video_to_update = video_result.scalar_one_or_none()
if video_to_update:
video_to_update.status = "failed"
await update_session.commit()
return GenerateVideoResponse( return GenerateVideoResponse(
success=False, success=False,
task_id=task_id, task_id=task_id,
@ -666,20 +673,41 @@ async def get_video_status(
message = status_messages.get(status, f"상태: {status}") message = status_messages.get(status, f"상태: {status}")
video_id = None video_id = None
# ⚠️ 소유자 검증이 필수다. creatomate_render_id 는 클라이언트가 보내는 값이라
# 남의 렌더 ID 로 이 엔드포인트를 부를 수 있다. 소유자를 안 거르면
# - 실패 분기: 남의 실패 건으로 **호출자에게 환불**이 나가고(크레딧 탈취),
# 원장 멱등 키(job_ref=피해자 task_id)가 소진돼 **피해자의 정당한 환불이 봉쇄**된다.
# - 성공 분기: 남의 영상이 **호출자 UUID 경로의 Blob** 으로 업로드된다.
# video 에는 user_uuid 가 없으므로(소유권은 project 에 있다) Project 를 조인한다.
async def _load_owned_video() -> Video | None:
row = (
await session.execute(
select(Video)
.join(Project, Video.project_id == Project.id)
.where(
Video.creatomate_render_id == creatomate_render_id,
Project.user_uuid == current_user.user_uuid,
)
.order_by(Video.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
if row is None:
logger.warning(
"[get_video_status] 소유자 아님 또는 영상 없음 — 후속 처리 생략, "
f"creatomate_render_id: {creatomate_render_id}, "
f"user: {current_user.user_uuid}"
)
return row
# succeeded 상태인 경우 백그라운드 태스크 실행 # succeeded 상태인 경우 백그라운드 태스크 실행
if status == "succeeded" and video_url: if status == "succeeded" and video_url:
# creatomate_render_id로 Video 조회하여 task_id 가져오기 # creatomate_render_id로 Video 조회하여 task_id 가져오기 (본인 것만)
video_result = await session.execute( video = await _load_owned_video()
select(Video)
.where(Video.creatomate_render_id == creatomate_render_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = video_result.scalar_one_or_none()
video_id = video.id
if video and video.status != "completed": if video and video.status != "completed":
video_id = video.id
# 이미 완료된 경우 백그라운드 작업 중복 실행 방지 # 이미 완료된 경우 백그라운드 작업 중복 실행 방지
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제 # 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
logger.info( logger.info(
@ -693,9 +721,25 @@ async def get_video_status(
user_uuid=current_user.user_uuid, user_uuid=current_user.user_uuid,
) )
elif video and video.status == "completed": elif video and video.status == "completed":
video_id = video.id
logger.debug( logger.debug(
f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}" f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}"
) )
elif status == "failed":
# 렌더 실패는 Creatomate 가 명시적으로 알려준 시점에만 알 수 있다.
# 크레딧은 generate_video 에서 선차감됐으므로 여기서 돌려줘야 한다.
# 조회를 본인 소유로 제한했으므로 환불 대상이 곧 소유자다.
video = await _load_owned_video()
if video:
video_id = video.id
if video.status != "failed":
await _fail_and_refund(
video.task_id,
creatomate_render_id=creatomate_render_id,
user_uuid=current_user.user_uuid,
reason="영상 생성 실패 환불 (Creatomate 렌더 실패)",
)
render_data = VideoRenderData( render_data = VideoRenderData(
id=result.get("id"), id=result.get("id"),
@ -951,57 +995,77 @@ async def get_all_videos(
) )
async def toggle_like( async def toggle_like(
video_id: int, video_id: int,
type: Literal["video", "ssul"] = Query(
default="video",
description="콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 반드시 함께 보낼 것",
),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> LikeToggleResponse: ) -> LikeToggleResponse:
"""영상 좋아요를 토글합니다. """영상/썰박스 좋아요를 토글합니다.
Write-Behind 패턴: Write-Behind 패턴:
1. Redis user-set / count를 즉시 원자적으로 업데이트 (Lua script) 1. Redis user-set / count를 즉시 원자적으로 업데이트 (Lua script)
2. dirty SET에 표시 → 스케줄러가 1분마다 MySQL에 반영 2. dirty SET에 표시 → 스케줄러가 1분마다 MySQL에 반영
DB write가 없으므로 고트래픽에서도 응답 지연 없음. DB write가 없으므로 고트래픽에서도 응답 지연 없음.
두 종류가 같은 테이블(video_reaction)·같은 Redis 로직을 쓰므로 엔드포인트도
하나다. type 은 ① 존재 확인 대상 ② Redis 키 접두 ③ backfill 컬럼만 가른다.
기본값이 "video" 라 기존 프론트 호출은 수정 없이 동작한다.
""" """
logger.info(f"[toggle_like] START - video_id: {video_id}, user: {current_user.user_uuid}") logger.info(
f"[toggle_like] START - type: {type}, id: {video_id}, user: {current_user.user_uuid}"
)
try: try:
# 영상 존재 확인 (DB read는 유지 — 404 처리 필수) # 대상 존재 확인 (DB read는 유지 — 404 처리 필수).
video_result = await session.execute( # id 가 종류별 독립 시퀀스라 반대쪽 테이블에 같은 id 가 있어도 잡으면 안 된다.
select(Video).where( if type == "ssul":
exists_q = select(SsulContent.id).where(
SsulContent.id == video_id,
SsulContent.status == "done",
SsulContent.is_deleted.is_(False),
)
# DB backfill 시 반응 행을 찾는 컬럼 — 썰박스 행은 content_id 가 채워져 있다
target_col = VideoReaction.content_id
else:
exists_q = select(Video.id).where(
Video.id == video_id, Video.id == video_id,
Video.status == "completed", Video.status == "completed",
Video.is_deleted == False, # noqa: E712 Video.is_deleted.is_(False),
) )
) target_col = VideoReaction.video_id
if video_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.") if (await session.execute(exists_q)).scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="콘텐츠를 찾을 수 없습니다.")
# Cold-start 보정: Redis에 데이터가 없으면 DB에서 backfill # Cold-start 보정: Redis에 데이터가 없으면 DB에서 backfill
count = await get_like_count(video_id) count = await get_like_count(video_id, ctype=type)
if count is None: if count is None:
# 카운트와 user-set 모두 없음 → DB에서 전체 복구 # 카운트와 user-set 모두 없음 → DB에서 전체 복구
user_uuids = (await session.execute( user_uuids = (await session.execute(
select(VideoReaction.user_uuid) select(VideoReaction.user_uuid).where(target_col == video_id)
.where(VideoReaction.video_id == video_id)
)).scalars().all() )).scalars().all()
await backfill_user_set(video_id, list(user_uuids)) await backfill_user_set(video_id, list(user_uuids), ctype=type)
await set_like_count(video_id, len(user_uuids)) await set_like_count(video_id, len(user_uuids), ctype=type)
elif count > 0: elif count > 0:
if not await is_user_set_exists(video_id): if not await is_user_set_exists(video_id, ctype=type):
# 카운트는 있지만 user-set이 증발한 경우 (부분 캐시 미스) # 카운트는 있지만 user-set이 증발한 경우 (부분 캐시 미스)
user_uuids = (await session.execute( user_uuids = (await session.execute(
select(VideoReaction.user_uuid) select(VideoReaction.user_uuid).where(target_col == video_id)
.where(VideoReaction.video_id == video_id)
)).scalars().all() )).scalars().all()
await backfill_user_set(video_id, list(user_uuids)) await backfill_user_set(video_id, list(user_uuids), ctype=type)
# Lua 스크립트로 원자적 토글 (race condition 방지) # Lua 스크립트로 원자적 토글 (race condition 방지)
is_liked, like_count = await toggle_like_atomic(video_id, current_user.user_uuid) is_liked, like_count = await toggle_like_atomic(
video_id, current_user.user_uuid, ctype=type
)
# dirty SET에 표시 → 스케줄러가 DB에 반영 # dirty SET에 표시 → 스케줄러가 DB에 반영
await mark_dirty(video_id, current_user.user_uuid) await mark_dirty(video_id, current_user.user_uuid, ctype=type)
logger.info( logger.info(
f"[toggle_like] SUCCESS - video_id: {video_id}, " f"[toggle_like] SUCCESS - type: {type}, id: {video_id}, "
f"is_liked: {is_liked}, count: {like_count}" f"is_liked: {is_liked}, count: {like_count}"
) )
return LikeToggleResponse(video_id=video_id, is_liked=is_liked, like_count=like_count) return LikeToggleResponse(video_id=video_id, is_liked=is_liked, like_count=like_count)