fix: 공유하기 기능 수정
This commit is contained in:
parent
e47a97476d
commit
5fcb964d7f
@ -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
@ -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="토글 후 전체 좋아요 수")
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user