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

View File

@ -1,5 +1,5 @@
from collections import defaultdict
from typing import List, Optional
from typing import List, Literal, Optional
from fastapi import HTTPException
from sqlalchemy import exists, select
@ -7,16 +7,54 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.comment.models import Comment
from app.comment.schemas.comment_schema import CommentItem, ReplyItem
from app.ssulbox.models import SsulContent
from app.utils.pagination import PaginatedResponse
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(
session: AsyncSession,
parent_id: int,
video_id: int,
content_type: ContentType,
target_id: int,
) -> None:
"""2-depth 제한 + 동일 video 검증."""
"""2-depth 제한 + 동일 대상 검증."""
result = await session.execute(
select(Comment).where(
Comment.id == parent_id,
@ -27,8 +65,11 @@ async def _validate_parent(
if parent is None:
raise HTTPException(status_code=400, detail="부모 댓글을 찾을 수 없습니다.")
if parent.video_id != video_id:
raise HTTPException(status_code=400, detail="다른 영상의 댓글에는 대댓글을 달 수 없습니다.")
# 부모가 같은 종류의 같은 대상에 달렸는지 본다. video_id 만 비교하면
# 썰박스 댓글(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:
raise HTTPException(status_code=400, detail="대댓글에는 대댓글을 달 수 없습니다. (최대 2-depth)")
@ -73,24 +114,19 @@ async def create_comment(
nickname: str,
content: str,
parent_id: Optional[int],
content_type: ContentType = "video",
) -> Comment:
# Video 존재 확인
video_result = await session.execute(
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="영상을 찾을 수 없습니다.")
# 대상 존재 확인
await _ensure_target_exists(session, content_type, video_id)
# parent_id 검증
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(
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,
nickname=nickname,
parent_id=parent_id,
@ -108,6 +144,7 @@ async def list_comments(
page: int,
page_size: int,
current_user_uuid: Optional[str],
content_type: ContentType = "video",
) -> PaginatedResponse[CommentItem]:
offset = (page - 1) * page_size
@ -121,9 +158,10 @@ async def list_comments(
.correlate(Comment)
)
# 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것
# 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것.
# 종류에 맞는 대상 컬럼으로 걸러야 같은 id 의 반대 종류 댓글이 섞이지 않는다.
parent_where = [
Comment.video_id == video_id,
_target_col(content_type) == video_id,
Comment.parent_id.is_(None),
(Comment.is_deleted == False) | has_live_reply, # noqa: E712
]

View File

@ -7,7 +7,17 @@ Social Media Models
from datetime import datetime
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.orm import Mapped, mapped_column, relationship
@ -53,6 +63,13 @@ class SocialUpload(Base):
__tablename__ = "social_upload"
__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_video_id", "video_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_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_content_seq",
"content_id",
"social_account_id",
"upload_seq",
),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -91,11 +114,19 @@ class SocialUpload(Base):
comment="사용자 UUID (User.user_uuid 참조)",
)
video_id: Mapped[int] = mapped_column(
# 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_social_upload_one_target).
video_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("video.id", ondelete="CASCADE"),
nullable=False,
comment="Video 외래키",
nullable=True,
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(
@ -242,8 +273,10 @@ class SocialUpload(Base):
# ==========================================================================
# Relationships
# ==========================================================================
video: Mapped["Video"] = relationship(
# 썰박스 업로드면 None 이다. 접근하는 쪽에서 반드시 방어할 것.
video: Mapped[Optional["Video"]] = relationship(
"Video",
foreign_keys=[video_id],
lazy="selectin",
)

View File

@ -3,7 +3,7 @@
"""
from datetime import datetime
from typing import Any, Optional
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
@ -13,7 +13,14 @@ from app.social.constants import PrivacyStatus, UploadStatus
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)")
title: str = Field(..., min_length=1, max_length=100, description="영상 제목")
description: Optional[str] = Field(
@ -77,7 +84,9 @@ class SocialUploadStatusResponse(BaseModel):
"""업로드 상태 조회 응답"""
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")
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
platform: str = Field(..., description="플랫폼명")
@ -119,7 +128,9 @@ class SocialUploadHistoryItem(BaseModel):
"""업로드 이력 아이템"""
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")
upload_seq: int = 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.worker.upload_task import process_social_upload
from app.user.models import User
from app.home.models import Project
from app.ssulbox.models import SsulContent
from app.video.models import Video
logger = logging.getLogger(__name__)
@ -55,26 +57,61 @@ class SocialUploadService:
logger.info(
f"[UPLOAD_SERVICE] 업로드 요청 - "
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}"
)
# 1. 영상 조회 및 검증
video_result = await session.execute(
select(Video).where(Video.id == body.video_id)
)
video = video_result.scalar_one_or_none()
# 1. 대상 조회 및 검증.
# video.id 와 ssul_content.id 는 값이 겹치는 독립 시퀀스라 content_type 으로
# 정확히 한 테이블만 봐야 한다 — 아니면 남의 다른 콘텐츠가 업로드된다.
# `target_col` 은 이후 중복 확인·채번에서도 같은 컬럼을 쓰기 위한 것이다.
# ⚠️ 소유자 필터가 필수다. 대상 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:
logger.warning(f"[UPLOAD_SERVICE] 영상 없음 - video_id: {body.video_id}")
raise VideoNotFoundError(video_id=body.video_id)
if not video:
logger.warning(f"[UPLOAD_SERVICE] 영상 없음 - video_id: {body.video_id}")
raise VideoNotFoundError(video_id=body.video_id)
if not video.result_movie_url:
logger.warning(f"[UPLOAD_SERVICE] 영상 URL 없음 - video_id: {body.video_id}")
raise VideoNotFoundError(
video_id=body.video_id,
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
)
if not video.result_movie_url:
logger.warning(f"[UPLOAD_SERVICE] 영상 URL 없음 - video_id: {body.video_id}")
raise VideoNotFoundError(
video_id=body.video_id,
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
)
# 2. 소셜 계정 조회 및 소유권 검증
account = await self._account_service.get_account_by_id(
@ -96,7 +133,7 @@ class SocialUploadService:
# 3-1. 진행 중인 업로드 확인 (즉시 pending 또는 uploading)
in_progress_result = await session.execute(
select(SocialUpload).where(
SocialUpload.video_id == body.video_id,
target_col == body.video_id,
SocialUpload.social_account_id == account.id,
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
or_(
@ -122,7 +159,7 @@ class SocialUploadService:
# 3-2. 미래 예약 업로드 확인
scheduled_result = await session.execute(
select(SocialUpload).where(
SocialUpload.video_id == body.video_id,
target_col == body.video_id,
SocialUpload.social_account_id == account.id,
SocialUpload.status == UploadStatus.PENDING.value,
SocialUpload.scheduled_at.isnot(None),
@ -148,7 +185,7 @@ class SocialUploadService:
# 4. 업로드 순번 계산
max_seq_result = await session.execute(
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,
)
)
@ -157,7 +194,9 @@ class SocialUploadService:
# 5. 새 업로드 레코드 생성
social_upload = SocialUpload(
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,
upload_seq=next_seq,
platform=account.platform,
@ -225,6 +264,7 @@ class SocialUploadService:
return SocialUploadStatusResponse(
upload_id=upload.id,
video_id=upload.video_id,
content_id=upload.content_id,
social_account_id=upload.social_account_id,
upload_seq=upload.upload_seq,
platform=upload.platform,
@ -318,6 +358,7 @@ class SocialUploadService:
SocialUploadHistoryItem(
upload_id=upload.id,
video_id=upload.video_id,
content_id=upload.content_id,
social_account_id=upload.social_account_id,
upload_seq=upload.upload_seq,
platform=upload.platform,

View File

@ -5,8 +5,6 @@ Social Upload Background Task
"""
import logging
import os
import tempfile
from pathlib import Path
from typing import Optional
@ -21,11 +19,12 @@ from config import social_upload_settings
from app.dashboard.tasks import insert_dashboard
from app.database.session import BackgroundSessionLocal
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.services import social_account_service
from app.social.uploader import get_uploader
from app.social.uploader.base import UploadMetadata
from app.ssulbox.models import SsulContent
from app.user.models import SocialAccount
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}")
return
# 2. Video 정보 조회
video_result = await session.execute(
select(Video).where(Video.id == upload.video_id)
)
video = video_result.scalar_one_or_none()
# 2. 대상 영상 조회.
# 병합 스키마: video_id / content_id 중 정확히 하나만 채워져 있다(CHECK).
# 채워진 쪽이 곧 종류다 — 썰박스면 ssul_content.video_url 을 쓴다.
if upload.content_id is not 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(
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(
upload_id=upload_id,
@ -206,7 +216,7 @@ async def process_social_upload(upload_id: int) -> None:
return
# 필요한 정보 저장
video_url = video.result_movie_url
video_url = source_url
platform = SocialPlatform(upload.platform)
upload_title = upload.title
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}"
)
except UploadQuotaExceededError as e:
except UploadQuotaExceededError:
logger.error(f"[SOCIAL_UPLOAD] API 할당량 초과 - upload_id: {upload_id}")
await _update_upload_status(
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.services.credit_service import deduct_credit_for_job
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.video.models import Video, VideoReaction
from app.video.services import unified_list
@ -55,7 +56,10 @@ from app.video.schemas.video_schema import (
VideoRenderData,
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
@ -181,10 +185,17 @@ async def generate_video(
# ===== 순차 쿼리 실행: Project, MarketingIntel, Lyric, Song, Image =====
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
# Project 조회
# Project 조회 (본인 소유만).
# ⚠️ task_id 는 경로 파라미터라 남의 값을 넣을 수 있다. 소유자를 안 거르면
# 남의 프로젝트로 영상을 만들 수 있고, 더 나쁘게는 크레딧 원장에
# job_ref=피해자 task_id 로 차감이 기록돼 **피해자의 정상 생성이
# "이미 차감됨"으로 처리**된다(멱등 키 오염).
project_result = await session.execute(
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())
.limit(1)
)
@ -521,17 +532,13 @@ async def generate_video(
)
import traceback
logger.error(traceback.format_exc())
# 외부 API 실패 시 Video 상태를 failed로 업데이트
from app.database.session import AsyncSessionLocal
async with AsyncSessionLocal() as update_session:
video_result = await update_session.execute(
select(Video).where(Video.id == video_id)
)
video_to_update = video_result.scalar_one_or_none()
if video_to_update:
video_to_update.status = "failed"
await update_session.commit()
# 외부 API 실패 시 Video 상태를 failed로 갱신하고, 선차감한 크레딧을 환불한다.
# 크레딧은 1단계에서 이미 차감됐으므로 여기서 돌려주지 않으면 그대로 소멸된다.
await _fail_and_refund(
task_id,
user_uuid=current_user.user_uuid,
reason="영상 생성 실패 환불 (Creatomate 요청 오류)",
)
return GenerateVideoResponse(
success=False,
task_id=task_id,
@ -666,20 +673,41 @@ async def get_video_status(
message = status_messages.get(status, f"상태: {status}")
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 상태인 경우 백그라운드 태스크 실행
if status == "succeeded" and video_url:
# creatomate_render_id로 Video 조회하여 task_id 가져오기
video_result = await session.execute(
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
# creatomate_render_id로 Video 조회하여 task_id 가져오기 (본인 것만)
video = await _load_owned_video()
if video and video.status != "completed":
video_id = video.id
# 이미 완료된 경우 백그라운드 작업 중복 실행 방지
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
logger.info(
@ -693,9 +721,25 @@ async def get_video_status(
user_uuid=current_user.user_uuid,
)
elif video and video.status == "completed":
video_id = video.id
logger.debug(
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(
id=result.get("id"),
@ -951,57 +995,77 @@ async def get_all_videos(
)
async def toggle_like(
video_id: int,
type: Literal["video", "ssul"] = Query(
default="video",
description="콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 반드시 함께 보낼 것",
),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> LikeToggleResponse:
"""영상 좋아요를 토글합니다.
"""영상/썰박스 좋아요를 토글합니다.
Write-Behind 패턴:
1. Redis user-set / count를 즉시 원자적으로 업데이트 (Lua script)
2. dirty SET에 표시 → 스케줄러가 1분마다 MySQL에 반영
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:
# 영상 존재 확인 (DB read는 유지 — 404 처리 필수)
video_result = await session.execute(
select(Video).where(
# 대상 존재 확인 (DB read는 유지 — 404 처리 필수).
# id 가 종류별 독립 시퀀스라 반대쪽 테이블에 같은 id 가 있어도 잡으면 안 된다.
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.status == "completed",
Video.is_deleted == False, # noqa: E712
Video.is_deleted.is_(False),
)
)
if video_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
target_col = VideoReaction.video_id
if (await session.execute(exists_q)).scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="콘텐츠를 찾을 수 없습니다.")
# 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:
# 카운트와 user-set 모두 없음 → DB에서 전체 복구
user_uuids = (await session.execute(
select(VideoReaction.user_uuid)
.where(VideoReaction.video_id == video_id)
select(VideoReaction.user_uuid).where(target_col == video_id)
)).scalars().all()
await backfill_user_set(video_id, list(user_uuids))
await set_like_count(video_id, len(user_uuids))
await backfill_user_set(video_id, list(user_uuids), ctype=type)
await set_like_count(video_id, len(user_uuids), ctype=type)
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_uuids = (await session.execute(
select(VideoReaction.user_uuid)
.where(VideoReaction.video_id == video_id)
select(VideoReaction.user_uuid).where(target_col == video_id)
)).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 방지)
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에 반영
await mark_dirty(video_id, current_user.user_uuid)
await mark_dirty(video_id, current_user.user_uuid, ctype=type)
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}"
)
return LikeToggleResponse(video_id=video_id, is_liked=is_liked, like_count=like_count)