fix: 공유하기 기능 수정

This commit is contained in:
김성경 2026-08-20 10:34:12 +09:00
parent e47a97476d
commit 5fcb964d7f
4 changed files with 1648 additions and 1464 deletions

View File

@ -5,7 +5,8 @@ castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `
인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기). 인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기).
""" """
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import func, select from sqlalchemy import func, select
from app.credit.exceptions import InsufficientCreditError from app.credit.exceptions import InsufficientCreditError
@ -28,8 +29,13 @@ from app.user.dependencies.auth import get_current_user, get_current_user_option
from app.user.models import User from app.user.models import User
# 좋아요는 castad video_reaction 에 병합돼 있다 (썰박스 행은 content_id 가 채워짐) # 좋아요는 castad video_reaction 에 병합돼 있다 (썰박스 행은 content_id 가 채워짐)
from app.video.models import VideoReaction from app.video.models import VideoReaction
from app.video.services.share_page import (
build_ssul_share_html,
get_ssul_share_data,
resolve_frontend_base_url,
)
from app.utils.logger import get_logger from app.utils.logger import get_logger
from config import ssulbox_settings from config import prj_settings, ssulbox_settings
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("ssulbox") logger = get_logger("ssulbox")
@ -179,6 +185,46 @@ async def get_task(
return SsulTaskStatus.model_validate(row) return SsulTaskStatus.model_validate(row)
@router.get(
"/share/{content_id}",
response_class=HTMLResponse,
summary="썰박스 공유용 Open Graph 페이지",
description="콘텐츠별 제목, 설명, 포스터 메타데이터가 포함된 공개 HTML을 반환합니다.",
responses={
200: {"description": "공유 메타데이터 HTML 반환"},
404: {"description": "공유 가능한 완료 콘텐츠를 찾을 수 없음"},
},
)
async def get_ssul_share_page(
content_id: int,
request: Request,
session: AsyncSession = Depends(get_session),
) -> HTMLResponse:
"""공개 공유 페이지를 반환하고 일반 브라우저는 썰 상세로 이동시킵니다."""
share_data = await get_ssul_share_data(session, content_id)
if share_data is None:
raise HTTPException(status_code=404, detail="공유 가능한 콘텐츠를 찾을 수 없습니다.")
share_url = str(request.url).split("?", maxsplit=1)[0]
html = build_ssul_share_html(
share_data,
share_url=share_url,
frontend_base_url=resolve_frontend_base_url(
request.headers,
prj_settings.SHARE_FRONTEND_URL,
),
configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL,
)
return HTMLResponse(
content=html,
headers={
"Cache-Control": "public, max-age=300",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
},
)
@router.get( @router.get(
"/{content_id}", "/{content_id}",
response_model=SsulDetailResponse, response_model=SsulDetailResponse,

View File

@ -61,7 +61,11 @@ from app.video.worker.video_task import (
_fail_and_refund, _fail_and_refund,
download_and_upload_video_to_blob, download_and_upload_video_to_blob,
) )
from app.video.services.share_page import build_video_share_html, get_video_share_data from app.video.services.share_page import (
build_video_share_html,
get_video_share_data,
resolve_frontend_base_url,
)
from config import creatomate_settings, prj_settings from config import creatomate_settings, prj_settings
@ -959,6 +963,8 @@ async def get_all_videos(
store_name=it.store_name, store_name=it.store_name,
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,
created_at=it.created_at, created_at=it.created_at,
like_count=it.like_count, like_count=it.like_count,
is_liked_by_me=it.is_liked_by_me, is_liked_by_me=it.is_liked_by_me,
@ -1104,7 +1110,10 @@ async def get_video_share_page(
html = build_video_share_html( html = build_video_share_html(
share_data, share_data,
share_url=share_url, share_url=share_url,
frontend_base_url=prj_settings.SHARE_FRONTEND_URL, frontend_base_url=resolve_frontend_base_url(
request.headers,
prj_settings.SHARE_FRONTEND_URL,
),
configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL, configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL,
) )
return HTMLResponse( return HTMLResponse(
@ -1196,6 +1205,8 @@ async def get_video_detail(
poster_url=video.poster_url, poster_url=video.poster_url,
store_name=project.store_name, store_name=project.store_name,
region=project.region or _extract_region_from_address(project.detail_region_info), region=project.region or _extract_region_from_address(project.detail_region_info),
title=video.title,
description=video.description,
created_at=video.created_at, created_at=video.created_at,
like_count=like_count, like_count=like_count,
is_liked_by_me=is_liked_by_me, is_liked_by_me=is_liked_by_me,

View File

@ -201,6 +201,8 @@ class VideoThumbnailItem(BaseModel):
store_name: str = Field(..., description="업체명") store_name: str = Field(..., description="업체명")
result_movie_url: str = Field(..., description="영상 URL") result_movie_url: str = Field(..., description="영상 URL")
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)") poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)")
title: Optional[str] = Field(None, description="SNS 업로드 제목")
description: Optional[str] = Field(None, description="SNS 업로드 설명")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수") like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
@ -219,6 +221,8 @@ class VideoDetailResponse(BaseModel):
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL") poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
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="지역명")
title: Optional[str] = Field(None, description="SNS 업로드 제목")
description: Optional[str] = Field(None, description="SNS 업로드 설명")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수") like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")

View File

@ -1,5 +1,6 @@
"""영상 공유 링크용 Open Graph HTML 생성 서비스.""" """콘텐츠 공유 링크용 Open Graph HTML 생성 서비스."""
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from html import escape from html import escape
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
@ -8,12 +9,14 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.home.models import Project from app.home.models import Project
from app.ssulbox.models import SsulContent
from app.video.models import Video from app.video.models import Video
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai" FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png" DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png"
DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png" DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class VideoShareData: class VideoShareData:
"""공유 페이지에 필요한 영상 및 프로젝트 정보.""" """공유 페이지에 필요한 영상 및 프로젝트 정보."""
@ -22,6 +25,20 @@ class VideoShareData:
poster_url: str | None poster_url: str | None
store_name: str store_name: str
region: str region: str
title: str | None = None
description: str | None = None
@dataclass(frozen=True, slots=True)
class SsulShareData:
"""공유 페이지에 필요한 썰박스 콘텐츠 정보."""
content_id: int
poster_url: str | None
store_name: str
region: str
title: str | None = None
description: str | None = None
async def get_video_share_data( async def get_video_share_data(
@ -33,6 +50,8 @@ async def get_video_share_data(
select( select(
Video.id, Video.id,
Video.poster_url, Video.poster_url,
Video.title,
Video.description,
Project.store_name, Project.store_name,
Project.region, Project.region,
) )
@ -53,6 +72,43 @@ async def get_video_share_data(
poster_url=row.poster_url, poster_url=row.poster_url,
store_name=row.store_name, store_name=row.store_name,
region=row.region, region=row.region,
title=row.title,
description=row.description,
)
async def get_ssul_share_data(
session: AsyncSession,
content_id: int,
) -> SsulShareData | None:
"""공유 가능한 완료 썰박스 콘텐츠를 조회합니다."""
result = await session.execute(
select(
SsulContent.id,
SsulContent.poster_url,
SsulContent.title,
SsulContent.description,
SsulContent.store_name,
SsulContent.region,
).where(
SsulContent.id == content_id,
SsulContent.status == "done",
SsulContent.is_deleted.is_(False),
SsulContent.video_url.is_not(None),
SsulContent.video_url != "",
)
)
row = result.one_or_none()
if row is None:
return None
return SsulShareData(
content_id=row.id,
poster_url=row.poster_url,
store_name=row.store_name,
region=row.region or "",
title=row.title,
description=row.description,
) )
@ -64,28 +120,61 @@ def build_video_share_html(
configured_default_image_url: str = "", configured_default_image_url: str = "",
) -> str: ) -> str:
"""영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다.""" """영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
return _build_share_html(
detail_path=f"/video/{data.video_id}",
poster_url=data.poster_url,
title=_share_title(data.title, data.store_name, fallback_store="ADO2 영상"),
description=_share_description(data.description),
share_url=share_url,
frontend_base_url=frontend_base_url,
configured_default_image_url=configured_default_image_url,
)
def build_ssul_share_html(
data: SsulShareData,
*,
share_url: str,
frontend_base_url: str,
configured_default_image_url: str = "",
) -> str:
"""썰박스 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
return _build_share_html(
detail_path=f"/ssul/{data.content_id}",
poster_url=data.poster_url,
title=_share_title(data.title, data.store_name, fallback_store="ADO2 썰"),
description=_share_description(data.description),
share_url=share_url,
frontend_base_url=frontend_base_url,
configured_default_image_url=configured_default_image_url,
)
def _build_share_html(
*,
detail_path: str,
poster_url: str | None,
title: str,
description: str,
share_url: str,
frontend_base_url: str,
configured_default_image_url: str,
) -> str:
"""크롤러용 OG 메타와, 사람용 프론트 상세 이동 링크를 포함한 HTML을 만듭니다."""
frontend_base = _normalise_frontend_base_url(frontend_base_url) frontend_base = _normalise_frontend_base_url(frontend_base_url)
detail_url = f"{frontend_base}/video/{data.video_id}" detail_url = f"{frontend_base}{detail_path}"
fallback_image_url = _resolve_default_image_url( fallback_image_url = _resolve_default_image_url(
configured_default_image_url, configured_default_image_url,
frontend_base, frontend_base,
share_url=share_url, share_url=share_url,
) )
image_url = _absolute_http_url(data.poster_url) or fallback_image_url image_url = _absolute_http_url(poster_url) or fallback_image_url
canonical_url = _absolute_http_url(share_url) or detail_url
store_name = _normalise_text(data.store_name, "ADO2 영상")
region = _normalise_text(data.region, "")
title = f"{store_name} | ADO2"
description = (
f"{region} · {store_name} 어떤 내용이 들어가야할지 정해야 합니다. 기존 영상 업로드시에 생성되는 내용을 사용하려면 db에 저장하고 업로드 할때마다 변경되는 부분을 전부 수정해야 합니다."
if region
else "ADO2 AI 마케팅 영상"
)
escaped_title = escape(title, quote=True) escaped_title = escape(title, quote=True)
escaped_description = escape(description, quote=True) escaped_description = escape(description, quote=True)
escaped_image_url = escape(image_url, quote=True) escaped_image_url = escape(image_url, quote=True)
escaped_share_url = escape(_absolute_http_url(share_url) or detail_url, quote=True) escaped_canonical_url = escape(canonical_url, quote=True)
escaped_detail_url = escape(detail_url, quote=True) escaped_detail_url = escape(detail_url, quote=True)
return f"""<!doctype html> return f"""<!doctype html>
@ -95,13 +184,13 @@ def build_video_share_html(
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escaped_title}</title> <title>{escaped_title}</title>
<meta name="description" content="{escaped_description}"> <meta name="description" content="{escaped_description}">
<link rel="canonical" href="{escaped_share_url}"> <link rel="canonical" href="{escaped_canonical_url}">
<meta property="og:title" content="{escaped_title}"> <meta property="og:title" content="{escaped_title}">
<meta property="og:description" content="{escaped_description}"> <meta property="og:description" content="{escaped_description}">
<meta property="og:image" content="{escaped_image_url}"> <meta property="og:image" content="{escaped_image_url}">
<meta property="og:image:alt" content="{escaped_title}"> <meta property="og:image:alt" content="{escaped_title}">
<meta property="og:url" content="{escaped_share_url}"> <meta property="og:url" content="{escaped_canonical_url}">
<meta property="og:type" content="video.other"> <meta property="og:type" content="video.other">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
@ -114,7 +203,7 @@ def build_video_share_html(
<main> <main>
<h1>{escaped_title}</h1> <h1>{escaped_title}</h1>
<p>{escaped_description}</p> <p>{escaped_description}</p>
<a id="continue-link" href="{escaped_detail_url}">영상 보기</a> <a id="continue-link" href="{escaped_detail_url}">콘텐츠 보기</a>
</main> </main>
<script> <script>
window.location.replace(document.getElementById("continue-link").href); window.location.replace(document.getElementById("continue-link").href);
@ -130,6 +219,40 @@ def _normalise_text(value: str | None, fallback: str) -> str:
return normalised or fallback return normalised or fallback
_OG_DESCRIPTION_MAX_LEN = 300
def _share_title(title: str | None, store_name: str, *, fallback_store: str) -> str:
"""저장된 SNS 제목을 쓰고, 없으면 가게명 폴백을 사용합니다."""
stored = _normalise_text(title, "")
if stored:
return stored
name = _normalise_text(store_name, fallback_store)
return f"{name} | ADO2"
def _share_description(description: str | None) -> str:
"""저장된 SNS 설명을 쓰고, 없으면 기본 문구를 사용합니다."""
stored = _normalise_text(description, "")
if stored:
if len(stored) > _OG_DESCRIPTION_MAX_LEN:
return stored[: _OG_DESCRIPTION_MAX_LEN - 1].rstrip() + "…"
return stored
return "ADO2 AI 마케팅 영상"
def resolve_frontend_base_url(headers: Mapping[str, str], fallback: str) -> str:
"""프록시가 넘긴 프론트 호스트를 우선해 canonical 기준 URL을 정합니다."""
forwarded_host = (headers.get("x-forwarded-host") or "").split(",")[0].strip()
if not forwarded_host:
return _normalise_frontend_base_url(fallback)
forwarded_proto = (headers.get("x-forwarded-proto") or "https").split(",")[0].strip().lower()
if forwarded_proto not in {"http", "https"}:
forwarded_proto = "https"
return _normalise_frontend_base_url(f"{forwarded_proto}://{forwarded_host}")
def _normalise_frontend_base_url(value: str) -> str: def _normalise_frontend_base_url(value: str) -> str:
"""프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다.""" """프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다."""
absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_URL absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_URL