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,
result_movie_url=it.movie_url,
poster_url=it.poster_url,
title=it.title,
description=it.description,
hashtags=it.hashtags,
created_at=it.created_at,
like_count=it.like_count,
comment_count=it.comment_count,

View File

@ -52,7 +52,7 @@ class SeoService:
)
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)
if result is None:
@ -90,6 +90,9 @@ class SeoService:
status_code=404, detail="콘텐츠를 찾을 수 없습니다."
)
if has_stored_sns_metadata(content):
return self._response_from_row(content)
input_data = {
"store_name": content.store_name or "",
"region": content.region or "",
@ -100,12 +103,15 @@ class SeoService:
out = await chatgpt.generate_structured_output(
get_ssul_upload_prompt(), input_data
)
return YoutubeDescriptionResponse(
result = YoutubeDescriptionResponse(
title=out.title,
description=out.description,
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:
raise
@ -129,7 +135,7 @@ class SeoService:
return None
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)
apply_sns_metadata(video, result.title, result.description, result.keywords)
@ -241,11 +247,11 @@ class SeoService:
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
)
def _response_from_video(self, video: Video) -> YoutubeDescriptionResponse:
def _response_from_row(self, row) -> YoutubeDescriptionResponse:
return YoutubeDescriptionResponse(
title=video.title or "",
description=video.description or "",
keywords=list(video.hashtags or []),
title=row.title or "",
description=row.description 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:
"""video 행에 SNS 제목이 이미 저장되어 있는지 확인합니다."""
return bool(video.title)
class SnsMetadataTarget(Protocol):
title: str | None
description: str | None
hashtags: list | None
def has_stored_sns_metadata(row: SnsMetadataTarget) -> bool:
"""행에 SNS 제목이 이미 저장되어 있는지 확인합니다."""
return bool(row.title)
def sns_metadata_changed(
video: Video,
row: SnsMetadataTarget,
title: str,
description: str | None,
tags: list[str] | None,
) -> bool:
"""게시 폼 값이 저장된 SNS 메타데이터와 다른지 비교합니다."""
stored_tags = list(video.hashtags or [])
stored_tags = list(row.hashtags or [])
incoming_tags = list(tags or [])
return (
(video.title or "") != title
or (video.description or "") != (description or "")
(row.title or "") != title
or (row.description or "") != (description or "")
or stored_tags != incoming_tags
)
def apply_sns_metadata(
video: Video,
row: SnsMetadataTarget,
title: str,
description: str | None,
hashtags: list[str] | None,
) -> None:
"""video 행에 SNS 메타데이터를 반영합니다."""
video.title = title
video.description = description
video.hashtags = list(hashtags or [])
"""행에 SNS 메타데이터를 반영합니다."""
row.title = title
row.description = description
row.hashtags = list(hashtags or [])

View File

@ -89,6 +89,7 @@ class SocialUploadService:
video_id=body.video_id,
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
)
target = content
else:
target_col = SocialUpload.video_id
# video 에는 user_uuid 가 없다 — 소유권은 project 에 있으므로 조인한다.
@ -113,13 +114,12 @@ class SocialUploadService:
video_id=body.video_id,
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
)
target = video
if body.content_type != "ssul" and sns_metadata_changed(
video, body.title, body.description, body.tags
):
apply_sns_metadata(video, body.title, body.description, body.tags)
if sns_metadata_changed(target, body.title, body.description, body.tags):
apply_sns_metadata(target, body.title, body.description, body.tags)
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. 소셜 계정 조회 및 소유권 검증

View File

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

View File

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

View File

@ -114,6 +114,9 @@ class SsulDetailResponse(BaseModel):
content_id: int = Field(..., description="콘텐츠 고유 ID")
scenario: str = Field(..., description="시나리오 코드")
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="업장명")
region: Optional[str] = Field(None, description="지역명")
created_at: datetime = Field(..., description="생성 일시")

View File

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