From e47a97476d5bd6073e3fc67af3996c2a7b79daea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Wed, 19 Aug 2026 15:31:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(ssulbox):=20SNS=20=EC=A0=9C=EB=AA=A9=C2=B7?= =?UTF-8?q?=EC=84=A4=EB=AA=85=C2=B7=ED=83=9C=EA=B7=B8=EB=A5=BC=20ssul=5Fco?= =?UTF-8?q?ntent=EC=97=90=20=EC=A0=80=EC=9E=A5=20=EC=98=81=EC=83=81?= =?UTF-8?q?=EA=B3=BC=20=EA=B0=99=EC=9D=B4=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=C2=B7SEO=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=ED=96=89=EC=97=90=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B0=EA=B3=A0,=20=EB=8B=A4=EC=9D=8C=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EB=B6=80=ED=84=B0=EB=8A=94=20DB=20=EA=B0=92=EC=9D=84?= =?UTF-8?q?=20=EC=93=B4=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app/archive/api/routers/v1/archive.py | 3 ++ app/social/services/seo_service.py | 22 +++++++---- app/social/services/sns_metadata.py | 37 ++++++++++++------- app/social/services/upload_service.py | 10 ++--- app/ssulbox/api/routers/v1/content.py | 3 ++ app/ssulbox/models.py | 35 +++++++++++++----- app/ssulbox/schemas/ssulbox_schema.py | 3 ++ app/video/services/unified_list.py | 12 ++++++ ...ation_2026_08_19_add_ssul_sns_metadata.sql | 19 ++++++++++ 9 files changed, 108 insertions(+), 36 deletions(-) create mode 100644 docs/database-schema/migration_2026_08_19_add_ssul_sns_metadata.sql diff --git a/app/archive/api/routers/v1/archive.py b/app/archive/api/routers/v1/archive.py index f35f5e9..3412518 100644 --- a/app/archive/api/routers/v1/archive.py +++ b/app/archive/api/routers/v1/archive.py @@ -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, diff --git a/app/social/services/seo_service.py b/app/social/services/seo_service.py index 57d3463..dad4463 100644 --- a/app/social/services/seo_service.py +++ b/app/social/services/seo_service.py @@ -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 []), ) diff --git a/app/social/services/sns_metadata.py b/app/social/services/sns_metadata.py index 3294cae..d716405 100644 --- a/app/social/services/sns_metadata.py +++ b/app/social/services/sns_metadata.py @@ -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 []) diff --git a/app/social/services/upload_service.py b/app/social/services/upload_service.py index b252f83..e3fe1f9 100644 --- a/app/social/services/upload_service.py +++ b/app/social/services/upload_service.py @@ -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. 소셜 계정 조회 및 소유권 검증 diff --git a/app/ssulbox/api/routers/v1/content.py b/app/ssulbox/api/routers/v1/content.py index 4bede76..b4ca710 100644 --- a/app/ssulbox/api/routers/v1/content.py +++ b/app/ssulbox/api/routers/v1/content.py @@ -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, diff --git a/app/ssulbox/models.py b/app/ssulbox/models.py index 1c95b44..b35b867 100644 --- a/app/ssulbox/models.py +++ b/app/ssulbox/models.py @@ -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, diff --git a/app/ssulbox/schemas/ssulbox_schema.py b/app/ssulbox/schemas/ssulbox_schema.py index ea84fdf..93b32fd 100644 --- a/app/ssulbox/schemas/ssulbox_schema.py +++ b/app/ssulbox/schemas/ssulbox_schema.py @@ -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="생성 일시") diff --git a/app/video/services/unified_list.py b/app/video/services/unified_list.py index 90bfdde..9ddaae7 100644 --- a/app/video/services/unified_list.py +++ b/app/video/services/unified_list.py @@ -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 ] diff --git a/docs/database-schema/migration_2026_08_19_add_ssul_sns_metadata.sql b/docs/database-schema/migration_2026_08_19_add_ssul_sns_metadata.sql new file mode 100644 index 0000000..1e00173 --- /dev/null +++ b/docs/database-schema/migration_2026_08_19_add_ssul_sns_metadata.sql @@ -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`;