feat: 좋아요·댓글·SNS 업로드를 콘텐츠 종류 공용으로 통합
This commit is contained in:
parent
ed1ba71bfe
commit
fb8febe462
@ -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
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
@ -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="플랫폼명")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user