From 8e9e12fe0b6ddb61a073c3fd83fb9dd1ee502e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Thu, 13 Aug 2026 17:23:06 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(video):=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=B2=AB=20=ED=94=84=EB=A0=88=EC=9E=84=20=EC=8D=B8=EB=84=A4?= =?UTF-8?q?=EC=9D=BC=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/archive/api/routers/v1/archive.py | 1 + app/comment/api/routers/v1/comment.py | 7 +- app/comment/models.py | 5 +- app/comment/schemas/comment_schema.py | 10 +- app/comment/services/comment.py | 18 +- app/utils/video_poster.py | 173 ++++++++++++++++++ app/video/api/routers/v1/video.py | 2 + app/video/models.py | 7 + app/video/schemas/video_schema.py | 6 +- app/video/worker/video_task.py | 45 ++++- ...ration_2026_08_13_add_video_poster_url.sql | 10 + 11 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 app/utils/video_poster.py create mode 100644 docs/database-schema/migration_2026_08_13_add_video_poster_url.sql diff --git a/app/archive/api/routers/v1/archive.py b/app/archive/api/routers/v1/archive.py index 17f5817..cc695d9 100644 --- a/app/archive/api/routers/v1/archive.py +++ b/app/archive/api/routers/v1/archive.py @@ -157,6 +157,7 @@ async def get_videos( region=project.region, task_id=video.task_id, result_movie_url=video.result_movie_url, + poster_url=video.poster_url, created_at=video.created_at, like_count=like_count_map.get(video.id) or 0, comment_count=comment_count or 0, diff --git a/app/comment/api/routers/v1/comment.py b/app/comment/api/routers/v1/comment.py index eb2fc76..46a4f01 100644 --- a/app/comment/api/routers/v1/comment.py +++ b/app/comment/api/routers/v1/comment.py @@ -46,7 +46,7 @@ router = APIRouter(prefix="/comment", tags=["Comment"]) - **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글) ## 참고 -- 작성자 정보는 응답에 포함되지 않습니다 (익명 정책). +- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보를 그대로 사용합니다 (클라이언트에서 지정 불가). - 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth). """, response_model=CommentCreateResponse, @@ -71,7 +71,7 @@ async def post_comment( session=session, video_id=video_id, user_uuid=current_user.user_uuid, - nickname=body.nickname, + nickname=current_user.nickname, content=body.content, parent_id=body.parent_id, ) @@ -79,6 +79,7 @@ async def post_comment( return CommentCreateResponse( id=comment.id, nickname=comment.nickname or "익명", + profile_image_url=current_user.profile_image_url, parent_id=comment.parent_id, content=comment.content, created_at=comment.created_at, @@ -101,7 +102,7 @@ async def post_comment( ## 참고 - 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다. -- 작성자 정보는 노출되지 않으며, is_mine으로 본인 댓글 여부만 확인 가능합니다. +- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보 기준이며, is_mine으로 본인 댓글 여부도 확인 가능합니다. - 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우). """, response_model=PaginatedResponse[CommentItem], diff --git a/app/comment/models.py b/app/comment/models.py index bfb3b22..6193cbc 100644 --- a/app/comment/models.py +++ b/app/comment/models.py @@ -17,7 +17,8 @@ class Comment(Base): 2-depth 구조 (최상위 댓글 + 대댓글 1단계). parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글. - 작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책). + 작성자 닉네임은 카카오 로그인 정보를 작성 시점에 그대로 저장한 스냅샷이며, + 프로필 이미지는 별도 컬럼 없이 응답 시 User 테이블을 조인해 최신값을 조회한다. """ __tablename__ = "comment" @@ -54,7 +55,7 @@ class Comment(Base): comment="NULL=최상위 댓글, 값=대댓글의 부모 id", ) nickname: Mapped[Optional[str]] = mapped_column( - String(50), nullable=True, comment="댓글 작성자 닉네임 (null이면 익명)" + String(50), nullable=True, comment="댓글 작성자 카카오 닉네임 스냅샷 (null이면 익명)" ) content: Mapped[str] = mapped_column( String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)" diff --git a/app/comment/schemas/comment_schema.py b/app/comment/schemas/comment_schema.py index dc6abb3..eec891e 100644 --- a/app/comment/schemas/comment_schema.py +++ b/app/comment/schemas/comment_schema.py @@ -5,7 +5,6 @@ from pydantic import BaseModel, Field class CommentCreateRequest(BaseModel): - nickname: Optional[str] = Field(None, min_length=1, max_length=50, description="작성자 닉네임 (미입력 시 익명)") content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)") parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id") @@ -14,7 +13,8 @@ class ReplyItem(BaseModel): """대댓글 응답""" id: int = Field(..., description="댓글 고유 ID") - nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')") + nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')") + profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)") content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)") is_deleted: bool = Field(..., description="삭제 여부") is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부") @@ -25,7 +25,8 @@ class CommentItem(BaseModel): """최상위 댓글 응답 — replies 포함""" id: int = Field(..., description="댓글 고유 ID") - nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')") + nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')") + profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)") content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)") is_deleted: bool = Field(..., description="삭제 여부") is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부") @@ -35,7 +36,8 @@ class CommentItem(BaseModel): class CommentCreateResponse(BaseModel): id: int = Field(..., description="생성된 댓글 고유 ID") - nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')") + nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')") + profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필)") parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)") content: str = Field(..., description="댓글 본문") created_at: datetime = Field(..., description="작성 일시") diff --git a/app/comment/services/comment.py b/app/comment/services/comment.py index ac680c3..d5c02be 100644 --- a/app/comment/services/comment.py +++ b/app/comment/services/comment.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.comment.models import Comment from app.comment.schemas.comment_schema import CommentItem, ReplyItem +from app.user.models import User from app.utils.pagination import PaginatedResponse from app.video.models import Video @@ -37,6 +38,7 @@ def _build_comment_items( parents: list, replies_map: dict, current_user_uuid: Optional[str], + profile_image_map: dict, ) -> List[CommentItem]: items = [] for c in parents: @@ -45,6 +47,7 @@ def _build_comment_items( ReplyItem( id=r.id, nickname=r.nickname or "익명", + profile_image_url=profile_image_map.get(r.user_uuid), content=None if r.is_deleted else r.content, is_deleted=r.is_deleted, is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False, @@ -56,6 +59,7 @@ def _build_comment_items( CommentItem( id=c.id, nickname=c.nickname or "익명", + profile_image_url=profile_image_map.get(c.user_uuid), content=None if c.is_deleted else c.content, is_deleted=c.is_deleted, is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False, @@ -70,7 +74,7 @@ async def create_comment( session: AsyncSession, video_id: int, user_uuid: str, - nickname: str, + nickname: Optional[str], content: str, parent_id: Optional[int], ) -> Comment: @@ -143,6 +147,7 @@ async def list_comments( parents = (await session.execute(parents_q)).scalars().all() replies_map: dict = defaultdict(list) + replies: list = [] if parents: parent_ids = [c.id for c in parents] replies_q = ( @@ -157,7 +162,16 @@ async def list_comments( for r in replies: replies_map[r.parent_id].append(r) - items = _build_comment_items(list(parents), replies_map, current_user_uuid) + # 작성자 프로필 이미지는 스냅샷을 저장하지 않고, 응답 시 User 테이블을 조인해 최신값을 조회한다. + user_uuids = {c.user_uuid for c in parents} | {r.user_uuid for r in replies} + profile_image_map: dict = {} + if user_uuids: + profile_q = select(User.user_uuid, User.profile_image_url).where( + User.user_uuid.in_(user_uuids) + ) + profile_image_map = {uuid: url for uuid, url in (await session.execute(profile_q)).all()} + + items = _build_comment_items(list(parents), replies_map, current_user_uuid, profile_image_map) return PaginatedResponse.create( items=items, diff --git a/app/utils/video_poster.py b/app/utils/video_poster.py new file mode 100644 index 0000000..e0fe665 --- /dev/null +++ b/app/utils/video_poster.py @@ -0,0 +1,173 @@ +"""영상 파일에서 SNS 공유용 포스터 이미지를 생성하고 저장합니다.""" + +import asyncio +from pathlib import Path + +from app.utils.logger import get_logger +from app.utils.upload_blob_as_request import AzureBlobUploader + +logger = get_logger("video_poster") + +FFMPEG_TIMEOUT_SECONDS = 30.0 +FFMPEG_CLEANUP_TIMEOUT_SECONDS = 5.0 +_STDERR_LOG_LIMIT = 500 + + +async def _kill_and_wait(process: asyncio.subprocess.Process) -> None: + """실행 중인 ffmpeg 프로세스를 종료하고 자원을 회수합니다.""" + try: + process.kill() + except ProcessLookupError: + pass + except Exception as exc: + logger.warning( + "[video_poster] ffmpeg 프로세스 종료에 실패했습니다: %s", + exc, + ) + + try: + await asyncio.wait_for( + process.wait(), + timeout=FFMPEG_CLEANUP_TIMEOUT_SECONDS, + ) + except TimeoutError: + logger.warning( + "[video_poster] 종료한 ffmpeg 프로세스 회수 시간이 초과되었습니다 " + "(timeout=%ss)", + FFMPEG_CLEANUP_TIMEOUT_SECONDS, + ) + except Exception as exc: + logger.warning( + "[video_poster] ffmpeg 프로세스 회수에 실패했습니다: %s", + exc, + ) + + +def _format_stderr(stderr: bytes) -> str: + """ffmpeg 표준 오류를 로그에 안전한 길이의 문자열로 변환합니다.""" + return stderr.decode("utf-8", errors="replace").strip()[-_STDERR_LOG_LIMIT:] + + +async def extract_first_frame(video_path: str | Path) -> bytes | None: + """로컬 영상의 첫 프레임을 JPEG 바이트로 추출하고 실패 시 ``None``을 반환합니다.""" + process: asyncio.subprocess.Process | None = None + + try: + process = await asyncio.create_subprocess_exec( + "ffmpeg", + "-nostdin", + "-hide_banner", + "-loglevel", + "error", + "-i", + str(video_path), + "-frames:v", + "1", + "-f", + "image2pipe", + "-c:v", + "mjpeg", + "pipe:1", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=FFMPEG_TIMEOUT_SECONDS, + ) + except TimeoutError: + await _kill_and_wait(process) + logger.warning( + "[video_poster] ffmpeg 첫 프레임 추출 시간이 초과되었습니다 " + "(path=%s, timeout=%ss)", + video_path, + FFMPEG_TIMEOUT_SECONDS, + ) + return None + + if process.returncode != 0: + logger.warning( + "[video_poster] ffmpeg 첫 프레임 추출에 실패했습니다 " + "(path=%s, returncode=%s, stderr=%s)", + video_path, + process.returncode, + _format_stderr(stderr), + ) + return None + + if not stdout: + logger.warning( + "[video_poster] ffmpeg가 빈 이미지를 반환했습니다 (path=%s)", + video_path, + ) + return None + + return stdout + + except asyncio.CancelledError: + if process is not None: + await _kill_and_wait(process) + raise + except Exception as exc: + if process is not None: + await _kill_and_wait(process) + logger.warning( + "[video_poster] 첫 프레임 추출 중 오류가 발생했습니다 " + "(path=%s, error=%s: %s)", + video_path, + type(exc).__name__, + exc, + ) + return None + + +async def generate_and_store_poster( + *, + video_path: str | Path, + user_uuid: str, + task_id: str, + file_stem: str, +) -> str | None: + """첫 프레임을 Blob에 저장하고 공개 URL을 반환하며, 실패 시 ``None``을 반환합니다.""" + try: + image_bytes = await extract_first_frame(video_path) + if image_bytes is None: + return None + + uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id) + uploaded = await uploader.upload_image_bytes( + image_bytes, + f"{file_stem}.jpg", + ) + if not uploaded: + logger.warning( + "[video_poster] 포스터 Blob 업로드에 실패했습니다 " + "(path=%s, task_id=%s)", + video_path, + task_id, + ) + return None + + if not uploader.public_url: + logger.warning( + "[video_poster] 포스터 업로드 후 공개 URL이 비어 있습니다 " + "(path=%s, task_id=%s)", + video_path, + task_id, + ) + return None + + return uploader.public_url + + except Exception as exc: + logger.warning( + "[video_poster] 포스터 생성 또는 저장 중 오류가 발생했습니다 " + "(path=%s, task_id=%s, error=%s: %s)", + video_path, + task_id, + type(exc).__name__, + exc, + ) + return None diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 9cc465b..557021d 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -987,6 +987,7 @@ async def get_all_videos( video_id=v.id, store_name=p.store_name, result_movie_url=v.result_movie_url, + poster_url=v.poster_url, created_at=v.created_at, like_count=like_count_map.get(v.id) or 0, is_liked_by_me=liked_map.get(v.id, False), @@ -1166,6 +1167,7 @@ async def get_video_detail( return VideoDetailResponse( video_id=video.id, result_movie_url=video.result_movie_url, + poster_url=video.poster_url, store_name=project.store_name, region=project.region or _extract_region_from_address(project.detail_region_info), created_at=video.created_at, diff --git a/app/video/models.py b/app/video/models.py index 888fc7b..8f26ad0 100644 --- a/app/video/models.py +++ b/app/video/models.py @@ -29,6 +29,7 @@ class Video(Base): task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식) status: 처리 상태 (pending, processing, completed, failed 등) result_movie_url: 생성된 영상 URL (S3, CDN 경로) + poster_url: 영상 첫 프레임 포스터 이미지 URL (SNS 공유 og:image용) created_at: 생성 일시 (자동 설정) Relationships: @@ -106,6 +107,12 @@ class Video(Base): comment="생성된 영상 URL", ) + poster_url: Mapped[Optional[str]] = mapped_column( + String(2048), + nullable=True, + comment="영상 첫 프레임 포스터 이미지 URL (SNS 공유용)", + ) + is_deleted: Mapped[bool] = mapped_column( Boolean, nullable=False, diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index f6599cb..f4e3c67 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -148,6 +148,7 @@ class VideoListItem(BaseModel): "region": "군산", "task_id": "019123ab-cdef-7890-abcd-ef1234567890", "result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4", + "poster_url": "http://localhost:8000/media/2025-01-15/video.jpg", "created_at": "2025-01-15T12:00:00" } """ @@ -157,6 +158,7 @@ class VideoListItem(BaseModel): region: Optional[str] = Field(None, description="지역명") task_id: str = Field(..., description="작업 고유 식별자") result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") + poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL") created_at: Optional[datetime] = Field(None, description="생성 일시") like_count: int = Field(0, description="좋아요 수") comment_count: int = Field(0, description="댓글 수 (대댓글 포함)") @@ -171,7 +173,8 @@ class VideoThumbnailItem(BaseModel): video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)") store_name: str = Field(..., description="업체명") - result_movie_url: str = Field(..., description="영상 URL — 프론트에서