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,
|
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,
|
||||||
|
|||||||
@ -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 []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 [])
|
||||||
|
|||||||
@ -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. 소셜 계정 조회 및 소유권 검증
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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="생성 일시")
|
||||||
|
|||||||
@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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