From 11c358da3cbe305f396907f5f570f657c288a36f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 18 Aug 2026 11:02:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(video):=20=EC=98=81=EC=83=81=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=EC=9A=A9=20Open=20Graph=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + app/video/api/routers/v1/video.py | 43 +++++++- app/video/services/share_page.py | 161 ++++++++++++++++++++++++++++++ config.py | 8 ++ 4 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 app/video/services/share_page.py diff --git a/README.md b/README.md index 7049211..785b8b7 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ PROJECT_DOMAIN=localhost:8000 # 프로젝트 도메인 (호스트:포 PROJECT_VERSION=0.1.0 # 프로젝트 버전 DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명 ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL +SHARE_FRONTEND_URL=https://ado2.o2osolution.ai # 공유 링크가 이동할 프론트엔드 공개 URL +SHARE_DEFAULT_IMAGE_URL=https://ado2.o2osolution.ai/assets/images/hero-background.png # 포스터가 없을 때의 절대 이미지 URL DEBUG=True # 디버그 모드 (True: 개발, False: 운영) # ================================ diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 557021d..c1b8370 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -17,7 +17,8 @@ import json from collections import defaultdict from typing import Literal -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request +from fastapi.responses import HTMLResponse from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -57,10 +58,11 @@ from app.video.schemas.video_schema import ( VideoRenderData, VideoThumbnailItem, ) +from app.video.services.share_page import build_video_share_html, get_video_share_data from app.video.worker.video_task import download_and_upload_video_to_blob -from config import creatomate_settings +from config import creatomate_settings, prj_settings logger = get_logger("video") @@ -1091,6 +1093,43 @@ async def toggle_like( raise HTTPException(status_code=500, detail=f"좋아요 처리에 실패했습니다: {str(e)}") +@router.get( + "/share/{video_id}", + response_class=HTMLResponse, + summary="영상 공유용 Open Graph 페이지", + description="영상별 제목, 설명, 포스터 메타데이터가 포함된 공개 HTML을 반환합니다.", + responses={ + 200: {"description": "공유 메타데이터 HTML 반환"}, + 404: {"description": "공유 가능한 완료 영상을 찾을 수 없음"}, + }, +) +async def get_video_share_page( + video_id: int, + request: Request, + session: AsyncSession = Depends(get_session), +) -> HTMLResponse: + """공개 공유 페이지를 반환하고 일반 브라우저는 영상 상세로 이동시킵니다.""" + share_data = await get_video_share_data(session, video_id) + if share_data is None: + raise HTTPException(status_code=404, detail="공유 가능한 영상을 찾을 수 없습니다.") + + share_url = str(request.url).split("?", maxsplit=1)[0] + html = build_video_share_html( + share_data, + share_url=share_url, + frontend_base_url=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( "/{video_id}", summary="단일 영상 상세 조회", diff --git a/app/video/services/share_page.py b/app/video/services/share_page.py new file mode 100644 index 0000000..8e9b8fb --- /dev/null +++ b/app/video/services/share_page.py @@ -0,0 +1,161 @@ +"""영상 공유 링크용 Open Graph HTML 생성 서비스.""" + +from dataclasses import dataclass +from html import escape +from urllib.parse import urlsplit, urlunsplit + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.home.models import Project +from app.video.models import Video + +FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai" +DEFAULT_SHARE_IMAGE_PATH = "/assets/images/hero-background.png" + + +@dataclass(frozen=True, slots=True) +class VideoShareData: + """공유 페이지에 필요한 영상 및 프로젝트 정보.""" + + video_id: int + poster_url: str | None + store_name: str + region: str + + +async def get_video_share_data( + session: AsyncSession, + video_id: int, +) -> VideoShareData | None: + """공유 가능한 완료 영상을 프로젝트 정보와 함께 조회합니다.""" + result = await session.execute( + select( + Video.id, + Video.poster_url, + Project.store_name, + Project.region, + ) + .join(Project, Video.project_id == Project.id) + .where( + Video.id == video_id, + Video.status == "completed", + Video.is_deleted.is_(False), + Project.is_deleted.is_(False), + ) + ) + row = result.one_or_none() + if row is None: + return None + + return VideoShareData( + video_id=row.id, + poster_url=row.poster_url, + store_name=row.store_name, + region=row.region, + ) + + +def build_video_share_html( + data: VideoShareData, + *, + share_url: str, + frontend_base_url: str, + configured_default_image_url: str = "", +) -> str: + """영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다.""" + frontend_base = _normalise_frontend_base_url(frontend_base_url) + detail_url = f"{frontend_base}/video/{data.video_id}" + fallback_image_url = _resolve_default_image_url( + configured_default_image_url, + frontend_base, + ) + image_url = _absolute_http_url(data.poster_url) or fallback_image_url + + store_name = _normalise_text(data.store_name, "ADO2 영상") + region = _normalise_text(data.region, "") + title = f"{store_name} | ADO2" + description = ( + f"{region} · ADO2 AI 마케팅 영상" + if region + else "ADO2 AI 마케팅 영상" + ) + + escaped_title = escape(title, quote=True) + escaped_description = escape(description, 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_detail_url = escape(detail_url, quote=True) + + return f""" + + + + + {escaped_title} + + + + + + + + + + + + + + + + + +
+

{escaped_title}

+

{escaped_description}

+ 영상 보기 +
+ + + +""" + + +def _normalise_text(value: str | None, fallback: str) -> str: + """메타데이터용 텍스트에서 불필요한 공백을 제거합니다.""" + normalised = " ".join((value or "").split()) + return normalised or fallback + + +def _normalise_frontend_base_url(value: str) -> str: + """프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다.""" + absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_URL + parts = urlsplit(absolute_url) + path = parts.path.rstrip("/") + return urlunsplit((parts.scheme, parts.netloc, path, "", "")) + + +def _resolve_default_image_url(configured_url: str, frontend_base: str) -> str: + """설정된 기본 이미지 또는 프론트엔드의 공개 기본 이미지를 반환합니다.""" + configured_absolute_url = _absolute_http_url(configured_url) + if configured_absolute_url: + return configured_absolute_url + return f"{frontend_base}{DEFAULT_SHARE_IMAGE_PATH}" + + +def _absolute_http_url(value: str | None) -> str | None: + """값이 절대 HTTP(S) URL인 경우에만 정리된 문자열을 반환합니다.""" + candidate = (value or "").strip() + if not candidate: + return None + + try: + parts = urlsplit(candidate) + except ValueError: + return None + + if parts.scheme.lower() not in {"http", "https"} or not parts.hostname: + return None + return candidate diff --git a/config.py b/config.py index b8853d0..14fdb5d 100644 --- a/config.py +++ b/config.py @@ -33,6 +33,14 @@ class ProjectSettings(BaseSettings): ADMIN_BASE_URL: str = Field(default="/admin") ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production") ADMIN_SESSION_MAX_AGE: int = Field(default=60 * 60 * 8) + SHARE_FRONTEND_URL: str = Field( + default="https://ado2.o2osolution.ai", + description="영상 공유 링크가 이동할 프론트엔드 공개 기준 URL", + ) + SHARE_DEFAULT_IMAGE_URL: str = Field( + default="https://ado2.o2osolution.ai/assets/images/hero-background.png", + description="포스터가 없는 영상 공유 시 사용할 절대 이미지 URL", + ) DEBUG: bool = Field(default=True) TIMEZONE: str = Field( default="Asia/Seoul",