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