feat(ssulbox): SNS 제목·설명·태그를 ssul_content에 저장

영상과 같이 업로드·SEO 결과를 행에 남기고, 다음 요청부터는 DB 값을 쓴다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
김성경 2026-08-19 15:31:44 +09:00
parent fde48e8674
commit e47a97476d
9 changed files with 108 additions and 36 deletions

View File

@ -95,6 +95,9 @@ async def get_videos(
task_id=it.task_id, task_id=it.task_id,
result_movie_url=it.movie_url, result_movie_url=it.movie_url,
poster_url=it.poster_url, poster_url=it.poster_url,
title=it.title,
description=it.description,
hashtags=it.hashtags,
created_at=it.created_at, created_at=it.created_at,
like_count=it.like_count, like_count=it.like_count,
comment_count=it.comment_count, comment_count=it.comment_count,

View File

@ -52,7 +52,7 @@ class SeoService:
) )
if has_stored_sns_metadata(video): if has_stored_sns_metadata(video):
return self._response_from_video(video) return self._response_from_row(video)
result = await self.generate_and_save_for_video(video.id, session) result = await self.generate_and_save_for_video(video.id, session)
if result is None: if result is None:
@ -90,6 +90,9 @@ class SeoService:
status_code=404, detail="콘텐츠를 찾을 수 없습니다." status_code=404, detail="콘텐츠를 찾을 수 없습니다."
) )
if has_stored_sns_metadata(content):
return self._response_from_row(content)
input_data = { input_data = {
"store_name": content.store_name or "", "store_name": content.store_name or "",
"region": content.region or "", "region": content.region or "",
@ -100,12 +103,15 @@ class SeoService:
out = await chatgpt.generate_structured_output( out = await chatgpt.generate_structured_output(
get_ssul_upload_prompt(), input_data get_ssul_upload_prompt(), input_data
) )
result = YoutubeDescriptionResponse(
return YoutubeDescriptionResponse(
title=out.title, title=out.title,
description=out.description, description=out.description,
keywords=out.keywords, keywords=out.keywords,
) )
apply_sns_metadata(content, result.title, result.description, result.keywords)
await session.commit()
logger.info(f"[SEO_SERVICE] Saved ssul metadata - content_id: {content_id}")
return result
except HTTPException: except HTTPException:
raise raise
@ -129,7 +135,7 @@ class SeoService:
return None return None
if has_stored_sns_metadata(video): if has_stored_sns_metadata(video):
return self._response_from_video(video) return self._response_from_row(video)
result = await self._generate_seo_description(video.task_id, session) result = await self._generate_seo_description(video.task_id, session)
apply_sns_metadata(video, result.title, result.description, result.keywords) apply_sns_metadata(video, result.title, result.description, result.keywords)
@ -241,11 +247,11 @@ class SeoService:
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}", detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
) )
def _response_from_video(self, video: Video) -> YoutubeDescriptionResponse: def _response_from_row(self, row) -> YoutubeDescriptionResponse:
return YoutubeDescriptionResponse( return YoutubeDescriptionResponse(
title=video.title or "", title=row.title or "",
description=video.description or "", description=row.description or "",
keywords=list(video.hashtags or []), keywords=list(row.hashtags or []),
) )

View File

@ -1,36 +1,45 @@
"""SNS 업로드용 영상 메타데이터 비교/반영 헬퍼.""" """SNS 업로드용 메타데이터 비교/반영 헬퍼.
from app.video.models import Video `video` 와 `ssul_content` 모두 `title` / `description` / `hashtags` 를 갖는다.
"""
from typing import Protocol
def has_stored_sns_metadata(video: Video) -> bool: class SnsMetadataTarget(Protocol):
"""video 행에 SNS 제목이 이미 저장되어 있는지 확인합니다.""" title: str | None
return bool(video.title) description: str | None
hashtags: list | None
def has_stored_sns_metadata(row: SnsMetadataTarget) -> bool:
"""행에 SNS 제목이 이미 저장되어 있는지 확인합니다."""
return bool(row.title)
def sns_metadata_changed( def sns_metadata_changed(
video: Video, row: SnsMetadataTarget,
title: str, title: str,
description: str | None, description: str | None,
tags: list[str] | None, tags: list[str] | None,
) -> bool: ) -> bool:
"""게시 폼 값이 저장된 SNS 메타데이터와 다른지 비교합니다.""" """게시 폼 값이 저장된 SNS 메타데이터와 다른지 비교합니다."""
stored_tags = list(video.hashtags or []) stored_tags = list(row.hashtags or [])
incoming_tags = list(tags or []) incoming_tags = list(tags or [])
return ( return (
(video.title or "") != title (row.title or "") != title
or (video.description or "") != (description or "") or (row.description or "") != (description or "")
or stored_tags != incoming_tags or stored_tags != incoming_tags
) )
def apply_sns_metadata( def apply_sns_metadata(
video: Video, row: SnsMetadataTarget,
title: str, title: str,
description: str | None, description: str | None,
hashtags: list[str] | None, hashtags: list[str] | None,
) -> None: ) -> None:
"""video 행에 SNS 메타데이터를 반영합니다.""" """행에 SNS 메타데이터를 반영합니다."""
video.title = title row.title = title
video.description = description row.description = description
video.hashtags = list(hashtags or []) row.hashtags = list(hashtags or [])

View File

@ -89,6 +89,7 @@ class SocialUploadService:
video_id=body.video_id, video_id=body.video_id,
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.", detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
) )
target = content
else: else:
target_col = SocialUpload.video_id target_col = SocialUpload.video_id
# video 에는 user_uuid 가 없다 — 소유권은 project 에 있으므로 조인한다. # video 에는 user_uuid 가 없다 — 소유권은 project 에 있으므로 조인한다.
@ -113,13 +114,12 @@ class SocialUploadService:
video_id=body.video_id, video_id=body.video_id,
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.", detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
) )
target = video
if body.content_type != "ssul" and sns_metadata_changed( if sns_metadata_changed(target, body.title, body.description, body.tags):
video, body.title, body.description, body.tags apply_sns_metadata(target, body.title, body.description, body.tags)
):
apply_sns_metadata(video, body.title, body.description, body.tags)
logger.info( logger.info(
f"[UPLOAD_SERVICE] video SNS 메타데이터 갱신 - video_id: {body.video_id}" f"[UPLOAD_SERVICE] SNS 메타데이터 갱신 - type: {body.content_type}, id: {body.video_id}"
) )
# 2. 소셜 계정 조회 및 소유권 검증 # 2. 소셜 계정 조회 및 소유권 검증

View File

@ -244,6 +244,9 @@ async def get_content_detail(
content_id=row.id, content_id=row.id,
scenario=row.scenario, scenario=row.scenario,
video_url=row.video_url, video_url=row.video_url,
poster_url=row.poster_url,
title=row.title,
description=row.description,
store_name=row.store_name or None, store_name=row.store_name or None,
region=row.region, region=row.region,
created_at=row.created_at, created_at=row.created_at,

View File

@ -31,6 +31,7 @@ from sqlalchemy import (
Text, Text,
func, func,
) )
from sqlalchemy.dialects.mysql import JSON
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.database.session import Base from app.database.session import Base
@ -164,9 +165,27 @@ class SsulContent(Base):
) )
poster_url: Mapped[Optional[str]] = mapped_column( poster_url: Mapped[Optional[str]] = mapped_column(
String(500), String(2048),
nullable=True, nullable=True,
comment="포스터 URL (없으면 프론트가 시나리오 표지로 대체)", comment="포스터 URL (SNS 공유 og:image. 없으면 프론트가 시나리오 표지로 대체)",
)
title: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="SNS 업로드 제목",
)
description: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="SNS 업로드 설명",
)
hashtags: Mapped[Optional[list]] = mapped_column(
JSON,
nullable=True,
comment="SNS 해시태그 목록",
) )
# ========================================================================== # ==========================================================================
@ -205,13 +224,11 @@ class SsulContent(Base):
comment="상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용", comment="상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용",
) )
# title / caption / views / like_count / comment_count 는 두지 않는다. # views / like_count / comment_count 는 두지 않는다.
# - castad `video` 도 제목을 갖지 않고 목록 표시는 store_name 으로 한다. # 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다
# SNS 업로드 제목·설명은 업로드 시점에 작성해 social_upload 에 담고,
# 다운로드 파일명은 프론트가 정한다.
# - 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다
# (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다). # (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다).
# 카운터를 들면 쓰기 경로마다 갱신해야 하고 드리프트가 생긴다. # 카운터를 들면 쓰기 경로마다 갱신해야 하고 드리프트가 생긴다.
# SNS 제목·설명·태그는 video 와 같이 이 테이블에 저장한다.
is_deleted: Mapped[bool] = mapped_column( is_deleted: Mapped[bool] = mapped_column(
Boolean, Boolean,

View File

@ -114,6 +114,9 @@ class SsulDetailResponse(BaseModel):
content_id: int = Field(..., description="콘텐츠 고유 ID") content_id: int = Field(..., description="콘텐츠 고유 ID")
scenario: str = Field(..., description="시나리오 코드") scenario: str = Field(..., description="시나리오 코드")
video_url: str = Field(..., description="완성 영상 URL") video_url: str = Field(..., description="완성 영상 URL")
poster_url: Optional[str] = Field(None, description="포스터 이미지 URL")
title: Optional[str] = Field(None, description="SNS 업로드 제목")
description: Optional[str] = Field(None, description="SNS 업로드 설명")
store_name: Optional[str] = Field(None, description="업장명") store_name: Optional[str] = Field(None, description="업장명")
region: Optional[str] = Field(None, description="지역명") region: Optional[str] = Field(None, description="지역명")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")

View File

@ -74,6 +74,9 @@ class UnifiedItem:
comment_count: int = 0 comment_count: int = 0
is_liked_by_me: bool = False is_liked_by_me: bool = False
poster_url: Optional[str] = None poster_url: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
hashtags: Optional[list] = None
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@ -192,6 +195,9 @@ def _video_branch(where: list, sort_by: str) -> Select:
Video.created_at.label("created_at"), Video.created_at.label("created_at"),
Video.task_id.label("task_id"), Video.task_id.label("task_id"),
Video.poster_url.label("poster_url"), Video.poster_url.label("poster_url"),
Video.title.label("title"),
Video.description.label("description"),
Video.hashtags.label("hashtags"),
] ]
if sort_by == SORT_LIKE: if sort_by == SORT_LIKE:
cols.append(_video_like_subq().label("sort_value")) cols.append(_video_like_subq().label("sort_value"))
@ -211,6 +217,9 @@ def _ssul_branch(where: list, sort_by: str) -> Select:
# UNION 은 컬럼 수·순서가 양쪽 같아야 한다. 썰박스에는 task_id 가 없다. # UNION 은 컬럼 수·순서가 양쪽 같아야 한다. 썰박스에는 task_id 가 없다.
literal("").label("task_id"), literal("").label("task_id"),
SsulContent.poster_url.label("poster_url"), SsulContent.poster_url.label("poster_url"),
SsulContent.title.label("title"),
SsulContent.description.label("description"),
SsulContent.hashtags.label("hashtags"),
] ]
if sort_by == SORT_LIKE: if sort_by == SORT_LIKE:
cols.append(_ssul_like_subq().label("sort_value")) cols.append(_ssul_like_subq().label("sort_value"))
@ -403,6 +412,9 @@ def _to_items(rows) -> list[UnifiedItem]:
created_at=r.created_at, created_at=r.created_at,
task_id=r.task_id or "", task_id=r.task_id or "",
poster_url=r.poster_url, poster_url=r.poster_url,
title=r.title,
description=r.description,
hashtags=list(r.hashtags) if r.hashtags else None,
) )
for r in rows for r in rows
] ]

View File

@ -0,0 +1,19 @@
-- ============================================================
-- Migration: ssul_content 에 SNS 메타데이터 컬럼 추가
-- Date: 2026-08-19
-- Description: video 와 동일하게 제목/설명/해시태그를 저장한다.
-- poster_url 은 이미 있으므로 길이만 video 와 맞춘다.
-- 관련 코드: app/ssulbox/models.py, app/social/services/seo_service.py
-- ============================================================
ALTER TABLE `ssul_content`
MODIFY COLUMN `poster_url` VARCHAR(2048) NULL
COMMENT '포스터 URL (SNS 공유 og:image. 없으면 프론트가 시나리오 표지로 대체)';
ALTER TABLE `ssul_content`
ADD COLUMN `title` VARCHAR(100) NULL
COMMENT 'SNS 업로드 제목' AFTER `poster_url`,
ADD COLUMN `description` TEXT NULL
COMMENT 'SNS 업로드 설명' AFTER `title`,
ADD COLUMN `hashtags` JSON NULL
COMMENT 'SNS 해시태그 목록' AFTER `description`;