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 폐기).
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import func, select
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
# 좋아요는 castad video_reaction 에 병합돼 있다 (썰박스 행은 content_id 가 채워짐)
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 config import ssulbox_settings
from config import prj_settings, ssulbox_settings
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("ssulbox")
@ -179,6 +185,46 @@ async def get_task(
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(
"/{content_id}",
response_model=SsulDetailResponse,

File diff suppressed because it is too large Load Diff

View File

@ -1,238 +1,242 @@
"""
Video API Schemas
영상 생성 관련 Pydantic 스키마를 정의합니다.
"""
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
# =============================================================================
# Response Schemas
# =============================================================================
class GenerateVideoResponse(BaseModel):
"""영상 생성 응답 스키마
Usage:
GET /video/generate/{task_id}
Returns the task IDs for tracking video generation.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"creatomate_render_id": "render-id-123456",
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
"error_message": None,
}
}
)
success: bool = Field(..., description="요청 성공 여부")
status: Optional[str] = Field(None, description="처리 상태 (subtitle_pending: 자막 미완료, completed: 정상 접수)")
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
message: str = Field(..., description="응답 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class VideoRenderData(BaseModel):
"""Creatomate 렌더링 결과 데이터"""
id: Optional[str] = Field(None, description="렌더 ID")
status: Optional[str] = Field(None, description="렌더 상태")
url: Optional[str] = Field(None, description="영상 URL")
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
video_id: Optional[int] = Field(None, description="Video id(DB)")
class PollingVideoResponse(BaseModel):
"""영상 생성 상태 조회 응답 스키마
Usage:
GET /video/status/{creatomate_render_id}
Creatomate API 작업 상태를 조회합니다.
Note:
상태 :
- planned: 예약됨
- waiting: 대기
- transcribing: 트랜스크립션
- rendering: 렌더링
- succeeded: 성공
- failed: 실패
Example Response (Success):
{
"success": true,
"status": "succeeded",
"message": "영상 생성이 완료되었습니다.",
"render_data": {
"id": "render-id",
"status": "succeeded",
"url": "https://...",
"snapshot_url": "https://..."
},
"raw_response": {...},
"error_message": null
}
"""
success: bool = Field(..., description="조회 성공 여부")
status: Optional[str] = Field(
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
)
message: str = Field(..., description="상태 메시지")
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class DownloadVideoResponse(BaseModel):
"""영상 다운로드 응답 스키마
Usage:
GET /video/download/{task_id}
Polls for video completion and returns project info with video URL.
Note:
상태 :
- processing: 영상 생성 진행 (result_movie_url은 null)
- completed: 영상 생성 완료 (result_movie_url 포함)
- failed: 영상 생성 실패
- not_found: task_id에 해당하는 Video 없음
- error: 조회 오류 발생
Example Response (Completed):
{
"success": true,
"status": "completed",
"message": "영상 다운로드가 완료되었습니다.",
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"created_at": "2025-01-15T12:00:00",
"error_message": null
}
"""
success: bool = Field(..., description="다운로드 성공 여부")
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
message: str = Field(..., description="응답 메시지")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class VideoListItem(BaseModel):
"""영상 목록 아이템 스키마
Usage:
GET /videos 응답의 개별 영상 정보
Example:
{
"video_id": 1,
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"poster_url": "http://localhost:8000/media/2025-01-15/video.jpg",
"created_at": "2025-01-15T12:00:00"
}
"""
# ⚠️ `video_id` 는 type 안에서만 유일하다 — `video.id` 와 `ssul_content.id` 는
# 각각 1부터 시작하는 독립 시퀀스다. 식별·삭제·상세 열기 모두
# **`(type, video_id)` 쌍**으로 다뤄야 한다.
# 특히 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지우므로,
# 썰박스 항목의 id 를 그대로 넘기면 **엉뚱한 ADO2 영상이 삭제된다.**
type: Literal["video", "ssul"] = Field(
default="video",
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스)",
)
video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(
default="",
description="작업 고유 식별자 (ADO2 전용. 썰박스는 개념이 없어 빈 문자열)",
)
result_movie_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 업로드 설명")
hashtags: Optional[List[str]] = Field(None, description="SNS 해시태그 목록")
created_at: Optional[datetime] = Field(None, description="생성 일시")
like_count: int = Field(0, description="좋아요 수")
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
is_liked_by_me: bool = Field(
False,
description="현재 로그인 사용자가 좋아요를 눌렀는지",
)
class VideoThumbnailItem(BaseModel):
"""ADO2 콘텐츠 갤러리용 최소 영상 정보 (썸네일 표시 + 상세 페이지 이동용)
Usage:
GET /video/all 응답의 개별 영상 정보
"""
# ⚠️ `video_id` 는 종류 안에서만 유일하다. `video.id` 와 `ssul_content.id` 가
# **둘 다 1부터 시작**하므로 식별자는 반드시 `(type, video_id)` 쌍으로 다뤄야 한다.
# 한 곳이라도 id 만 쓰면 다른 종류의 콘텐츠가 열린다.
type: Literal["video", "ssul"] = Field(
default="video",
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스). video_id 와 쌍으로 식별한다",
)
video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)")
store_name: str = Field(..., description="업체명")
result_movie_url: str = Field(..., description="영상 URL")
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)")
created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
class VideoDetailResponse(BaseModel):
"""단일 영상 상세 응답
Usage:
GET /video/{video_id}
"""
video_id: int = Field(..., description="영상 고유 ID")
result_movie_url: str = Field(..., description="영상 URL")
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
class LikeToggleResponse(BaseModel):
"""좋아요 토글 응답
Usage:
POST /video/{video_id}/like
"""
video_id: int = Field(..., description="영상 고유 ID")
is_liked: bool = Field(..., description="토글 후 상태 (true=좋아요 누름, false=취소됨)")
like_count: int = Field(..., description="토글 후 전체 좋아요 수")
"""
Video API Schemas
영상 생성 관련 Pydantic 스키마를 정의합니다.
"""
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
# =============================================================================
# Response Schemas
# =============================================================================
class GenerateVideoResponse(BaseModel):
"""영상 생성 응답 스키마
Usage:
GET /video/generate/{task_id}
Returns the task IDs for tracking video generation.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"creatomate_render_id": "render-id-123456",
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
"error_message": None,
}
}
)
success: bool = Field(..., description="요청 성공 여부")
status: Optional[str] = Field(None, description="처리 상태 (subtitle_pending: 자막 미완료, completed: 정상 접수)")
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
message: str = Field(..., description="응답 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class VideoRenderData(BaseModel):
"""Creatomate 렌더링 결과 데이터"""
id: Optional[str] = Field(None, description="렌더 ID")
status: Optional[str] = Field(None, description="렌더 상태")
url: Optional[str] = Field(None, description="영상 URL")
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
video_id: Optional[int] = Field(None, description="Video id(DB)")
class PollingVideoResponse(BaseModel):
"""영상 생성 상태 조회 응답 스키마
Usage:
GET /video/status/{creatomate_render_id}
Creatomate API 작업 상태를 조회합니다.
Note:
상태 :
- planned: 예약됨
- waiting: 대기
- transcribing: 트랜스크립션
- rendering: 렌더링
- succeeded: 성공
- failed: 실패
Example Response (Success):
{
"success": true,
"status": "succeeded",
"message": "영상 생성이 완료되었습니다.",
"render_data": {
"id": "render-id",
"status": "succeeded",
"url": "https://...",
"snapshot_url": "https://..."
},
"raw_response": {...},
"error_message": null
}
"""
success: bool = Field(..., description="조회 성공 여부")
status: Optional[str] = Field(
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
)
message: str = Field(..., description="상태 메시지")
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class DownloadVideoResponse(BaseModel):
"""영상 다운로드 응답 스키마
Usage:
GET /video/download/{task_id}
Polls for video completion and returns project info with video URL.
Note:
상태 :
- processing: 영상 생성 진행 (result_movie_url은 null)
- completed: 영상 생성 완료 (result_movie_url 포함)
- failed: 영상 생성 실패
- not_found: task_id에 해당하는 Video 없음
- error: 조회 오류 발생
Example Response (Completed):
{
"success": true,
"status": "completed",
"message": "영상 다운로드가 완료되었습니다.",
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"created_at": "2025-01-15T12:00:00",
"error_message": null
}
"""
success: bool = Field(..., description="다운로드 성공 여부")
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
message: str = Field(..., description="응답 메시지")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class VideoListItem(BaseModel):
"""영상 목록 아이템 스키마
Usage:
GET /videos 응답의 개별 영상 정보
Example:
{
"video_id": 1,
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"poster_url": "http://localhost:8000/media/2025-01-15/video.jpg",
"created_at": "2025-01-15T12:00:00"
}
"""
# ⚠️ `video_id` 는 type 안에서만 유일하다 — `video.id` 와 `ssul_content.id` 는
# 각각 1부터 시작하는 독립 시퀀스다. 식별·삭제·상세 열기 모두
# **`(type, video_id)` 쌍**으로 다뤄야 한다.
# 특히 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지우므로,
# 썰박스 항목의 id 를 그대로 넘기면 **엉뚱한 ADO2 영상이 삭제된다.**
type: Literal["video", "ssul"] = Field(
default="video",
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스)",
)
video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(
default="",
description="작업 고유 식별자 (ADO2 전용. 썰박스는 개념이 없어 빈 문자열)",
)
result_movie_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 업로드 설명")
hashtags: Optional[List[str]] = Field(None, description="SNS 해시태그 목록")
created_at: Optional[datetime] = Field(None, description="생성 일시")
like_count: int = Field(0, description="좋아요 수")
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
is_liked_by_me: bool = Field(
False,
description="현재 로그인 사용자가 좋아요를 눌렀는지",
)
class VideoThumbnailItem(BaseModel):
"""ADO2 콘텐츠 갤러리용 최소 영상 정보 (썸네일 표시 + 상세 페이지 이동용)
Usage:
GET /video/all 응답의 개별 영상 정보
"""
# ⚠️ `video_id` 는 종류 안에서만 유일하다. `video.id` 와 `ssul_content.id` 가
# **둘 다 1부터 시작**하므로 식별자는 반드시 `(type, video_id)` 쌍으로 다뤄야 한다.
# 한 곳이라도 id 만 쓰면 다른 종류의 콘텐츠가 열린다.
type: Literal["video", "ssul"] = Field(
default="video",
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스). video_id 와 쌍으로 식별한다",
)
video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)")
store_name: str = Field(..., description="업체명")
result_movie_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 업로드 설명")
created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
class VideoDetailResponse(BaseModel):
"""단일 영상 상세 응답
Usage:
GET /video/{video_id}
"""
video_id: int = Field(..., description="영상 고유 ID")
result_movie_url: str = Field(..., description="영상 URL")
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
store_name: 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="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
class LikeToggleResponse(BaseModel):
"""좋아요 토글 응답
Usage:
POST /video/{video_id}/like
"""
video_id: int = Field(..., description="영상 고유 ID")
is_liked: bool = Field(..., description="토글 후 상태 (true=좋아요 누름, false=취소됨)")
like_count: int = Field(..., description="토글 후 전체 좋아요 수")

View File

@ -1,5 +1,6 @@
"""영상 공유 링크용 Open Graph HTML 생성 서비스."""
"""콘텐츠 공유 링크용 Open Graph HTML 생성 서비스."""
from collections.abc import Mapping
from dataclasses import dataclass
from html import escape
from urllib.parse import urlsplit, urlunsplit
@ -8,12 +9,14 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.home.models import Project
from app.ssulbox.models import SsulContent
from app.video.models import Video
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png"
DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png"
@dataclass(frozen=True, slots=True)
class VideoShareData:
"""공유 페이지에 필요한 영상 및 프로젝트 정보."""
@ -22,6 +25,20 @@ class VideoShareData:
poster_url: str | None
store_name: 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(
@ -33,6 +50,8 @@ async def get_video_share_data(
select(
Video.id,
Video.poster_url,
Video.title,
Video.description,
Project.store_name,
Project.region,
)
@ -53,6 +72,43 @@ async def get_video_share_data(
poster_url=row.poster_url,
store_name=row.store_name,
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 = "",
) -> str:
"""영상별 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)
detail_url = f"{frontend_base}/video/{data.video_id}"
detail_url = f"{frontend_base}{detail_path}"
fallback_image_url = _resolve_default_image_url(
configured_default_image_url,
frontend_base,
share_url=share_url,
)
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} · {store_name} 어떤 내용이 들어가야할지 정해야 합니다. 기존 영상 업로드시에 생성되는 내용을 사용하려면 db에 저장하고 업로드 할때마다 변경되는 부분을 전부 수정해야 합니다."
if region
else "ADO2 AI 마케팅 영상"
)
image_url = _absolute_http_url(poster_url) or fallback_image_url
canonical_url = _absolute_http_url(share_url) or detail_url
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_canonical_url = escape(canonical_url, quote=True)
escaped_detail_url = escape(detail_url, quote=True)
return f"""<!doctype html>
@ -95,13 +184,13 @@ def build_video_share_html(
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escaped_title}</title>
<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:description" content="{escaped_description}">
<meta property="og:image" content="{escaped_image_url}">
<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 name="twitter:card" content="summary_large_image">
@ -114,7 +203,7 @@ def build_video_share_html(
<main>
<h1>{escaped_title}</h1>
<p>{escaped_description}</p>
<a id="continue-link" href="{escaped_detail_url}">영상 보기</a>
<a id="continue-link" href="{escaped_detail_url}">콘텐츠 보기</a>
</main>
<script>
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
_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:
"""프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다."""
absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_URL