feat(ssulbox): SNS 제목·설명·태그를 ssul_content에 저장
영상과 같이 업로드·SEO 결과를 행에 남기고, 다음 요청부터는 DB 값을 쓴다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
fde48e8674
commit
e47a97476d
@ -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,
|
||||
|
||||
@ -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 []),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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 [])
|
||||
|
||||
@ -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. 소셜 계정 조회 및 소유권 검증
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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` 상관 서브쿼리로 집계한다
|
||||
# (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다).
|
||||
# 카운터를 들면 쓰기 경로마다 갱신해야 하고 드리프트가 생긴다.
|
||||
# views / like_count / comment_count 는 두지 않는다.
|
||||
# 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다
|
||||
# (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다).
|
||||
# 카운터를 들면 쓰기 경로마다 갱신해야 하고 드리프트가 생긴다.
|
||||
# SNS 제목·설명·태그는 video 와 같이 이 테이블에 저장한다.
|
||||
|
||||
is_deleted: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
|
||||
@ -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="생성 일시")
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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`;
|
||||
Loading…
Reference in New Issue
Block a user