350 lines
12 KiB
Python
350 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""썰박스 API — 장소 검색 · 생성 요청 · 진행 폴링.
|
|
|
|
castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `/ssul` 로 노출한다.
|
|
인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기).
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse
|
|
from sqlalchemy import func, select
|
|
|
|
from app.credit.exceptions import InsufficientCreditError
|
|
from app.database.like_cache import get_like_count, is_user_liked, set_like_count
|
|
from app.database.session import AsyncSessionLocal, get_session
|
|
from app.ssulbox.constants import ORPHAN_STATUSES, is_generation_available
|
|
from app.ssulbox.exceptions import GenerationUnavailableError, TaskNotFoundError
|
|
from app.ssulbox.models import SsulContent
|
|
from app.ssulbox.schemas.ssulbox_schema import (
|
|
SsulActiveTasksResponse,
|
|
SsulCreateRequest,
|
|
SsulCreateResponse,
|
|
SsulDeleteResponse,
|
|
SsulDetailResponse,
|
|
SsulTaskStatus,
|
|
)
|
|
from app.ssulbox.services import task_service
|
|
from app.ssulbox.worker import job_manager
|
|
from app.user.dependencies.auth import get_current_user, get_current_user_optional
|
|
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 prj_settings, ssulbox_settings
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
logger = get_logger("ssulbox")
|
|
|
|
router = APIRouter(prefix="/ssul", tags=["Ssulbox"])
|
|
|
|
|
|
|
|
@router.post(
|
|
"/create",
|
|
response_model=SsulCreateResponse,
|
|
summary="썰박스 생성 요청",
|
|
description="""
|
|
썰박스 생성을 요청합니다.
|
|
|
|
## 크레딧
|
|
- **요청 시점에 크레딧이 선차감됩니다.** 완료 시점이 아닙니다.
|
|
- 생성이 실패하면 자동으로 환불됩니다.
|
|
- 잔액이 부족하면 402 를 반환하며 잡도 생성되지 않습니다.
|
|
|
|
## 진행 확인
|
|
응답의 `id` 로 `GET /ssul/tasks/{id}` 를 폴링하세요.
|
|
권장 간격은 응답의 `poll_interval_seconds` 입니다.
|
|
""",
|
|
responses={
|
|
200: {"description": "요청 접수"},
|
|
401: {"description": "인증 실패"},
|
|
402: {"description": "크레딧 부족"},
|
|
503: {"description": "생성 기능 비활성 (Gemini API 키 미설정)"},
|
|
},
|
|
)
|
|
async def create_ssul(
|
|
body: SsulCreateRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
) -> SsulCreateResponse:
|
|
if not is_generation_available():
|
|
raise GenerationUnavailableError("Gemini API 키가 설정되지 않았습니다.")
|
|
|
|
# 행 삽입 + 크레딧 선차감을 한 트랜잭션으로 묶는다.
|
|
# 외부 API 호출이 없으므로 Depends(get_session) 대신 짧게 열고 닫는다.
|
|
async with AsyncSessionLocal() as session:
|
|
try:
|
|
row = await task_service.create_task(
|
|
session,
|
|
user_uuid=current_user.user_uuid,
|
|
scenario=body.scenario,
|
|
input_text=body.input,
|
|
scenes=body.scenes,
|
|
seconds=body.seconds,
|
|
store_name=body.store_name,
|
|
road_address=body.road_address,
|
|
address=body.address,
|
|
)
|
|
await session.commit()
|
|
content_id = row.id
|
|
except InsufficientCreditError:
|
|
await session.rollback()
|
|
logger.info(
|
|
f"[create_ssul] INSUFFICIENT CREDIT user={current_user.user_uuid}"
|
|
)
|
|
raise
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
|
|
# 커밋이 끝난 뒤에 큐잉한다 — 워커가 아직 없는 행을 조회하면 안 된다.
|
|
# create_job 은 앱 이벤트 루프를 캡처하므로 반드시 요청 핸들러에서 호출한다.
|
|
# place URL 해석은 워커가 한다 — 10초 이상 걸려 요청을 막으면 안 된다.
|
|
job_manager.create_job(
|
|
content_id,
|
|
body.scenario,
|
|
body.input,
|
|
body.scenes,
|
|
body.seconds,
|
|
store_name=body.store_name,
|
|
road_address=body.road_address,
|
|
address=body.address,
|
|
)
|
|
|
|
return SsulCreateResponse(
|
|
id=content_id,
|
|
status="queued",
|
|
poll_interval_seconds=ssulbox_settings.SSULBOX_POLL_HINT_SECONDS,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/tasks/active",
|
|
response_model=SsulActiveTasksResponse,
|
|
summary="진행 중인 내 생성 잡",
|
|
description="""
|
|
새로고침·새 탭 진입 시 진행 상태를 복구하는 데 씁니다.
|
|
|
|
클라이언트의 localStorage 는 새 탭에서 초기화되므로 **서버가 권위**입니다.
|
|
""",
|
|
)
|
|
async def get_active_tasks(
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> SsulActiveTasksResponse:
|
|
rows = (
|
|
(
|
|
await session.execute(
|
|
select(SsulContent)
|
|
.where(
|
|
SsulContent.user_uuid == current_user.user_uuid,
|
|
SsulContent.status.in_(ORPHAN_STATUSES),
|
|
SsulContent.is_deleted.is_(False),
|
|
)
|
|
.order_by(SsulContent.created_at.desc())
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return SsulActiveTasksResponse(
|
|
items=[SsulTaskStatus.model_validate(r) for r in rows]
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/tasks/{content_id}",
|
|
response_model=SsulTaskStatus,
|
|
summary="생성 진행 상태 (폴링)",
|
|
description="""
|
|
생성 진행 상태를 반환합니다. 프론트가 3초마다 폴링합니다.
|
|
|
|
- `step` 은 완료한 단계 수(0~4)입니다. 0=준비, 4=영상 합성 완료.
|
|
- `video_url` 은 `status=done` 일 때만 채워집니다.
|
|
- 남의 잡은 404 로 처리합니다(존재 여부를 노출하지 않습니다).
|
|
""",
|
|
responses={
|
|
200: {"description": "조회 성공"},
|
|
401: {"description": "인증 실패"},
|
|
404: {"description": "잡을 찾을 수 없음"},
|
|
},
|
|
)
|
|
async def get_task(
|
|
content_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> SsulTaskStatus:
|
|
row = await session.get(SsulContent, content_id)
|
|
# 남의 잡이면 존재 여부를 알리지 않고 동일하게 404
|
|
if row is None or row.user_uuid != current_user.user_uuid:
|
|
raise TaskNotFoundError()
|
|
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,
|
|
summary="썰박스 콘텐츠 공개 상세",
|
|
description="""
|
|
완성된 썰박스 콘텐츠의 상세 정보를 반환합니다. **비로그인도 접근 가능**합니다
|
|
(공유 링크 `/ssul/{content_id}` 가 이 API 를 씁니다 — castad `/video/{video_id}` 와 동일한 패턴).
|
|
|
|
- `status=done` 이고 삭제되지 않은 콘텐츠만 조회됩니다.
|
|
- `is_liked_by_me` 는 비로그인이면 항상 false 입니다.
|
|
- 댓글은 `GET /comment/video/{content_id}?type=ssul` 로 따로 조회합니다.
|
|
""",
|
|
responses={
|
|
200: {"description": "조회 성공"},
|
|
404: {"description": "콘텐츠를 찾을 수 없음"},
|
|
},
|
|
)
|
|
async def get_content_detail(
|
|
content_id: int,
|
|
current_user: User | None = Depends(get_current_user_optional),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> SsulDetailResponse:
|
|
row = await session.get(SsulContent, content_id)
|
|
if (
|
|
row is None
|
|
or row.is_deleted
|
|
or row.status != "done"
|
|
or not row.video_url
|
|
):
|
|
raise TaskNotFoundError()
|
|
|
|
# 좋아요 수: Redis 우선, 미스면 DB 집계 후 캐시에 채운다 (castad 상세와 동일)
|
|
like_count = await get_like_count(content_id, ctype="ssul")
|
|
if like_count is None:
|
|
like_count = (
|
|
await session.execute(
|
|
select(func.count(VideoReaction.id)).where(
|
|
VideoReaction.content_id == content_id
|
|
)
|
|
)
|
|
).scalar() or 0
|
|
await set_like_count(content_id, like_count, ctype="ssul")
|
|
|
|
is_liked = False
|
|
if current_user:
|
|
cached = await is_user_liked(
|
|
content_id, current_user.user_uuid, ctype="ssul"
|
|
)
|
|
if cached is None:
|
|
# user-set 콜드 미스 — 응답 정확성만 필요하므로 DB 존재 확인으로 대체
|
|
is_liked = (
|
|
await session.execute(
|
|
select(VideoReaction.id).where(
|
|
VideoReaction.content_id == content_id,
|
|
VideoReaction.user_uuid == current_user.user_uuid,
|
|
)
|
|
)
|
|
).scalar_one_or_none() is not None
|
|
else:
|
|
is_liked = cached
|
|
|
|
return SsulDetailResponse(
|
|
content_id=row.id,
|
|
scenario=row.scenario,
|
|
video_url=row.video_url,
|
|
poster_url=row.poster_url,
|
|
title=row.title,
|
|
description=row.description,
|
|
store_name=row.store_name or None,
|
|
region=row.region,
|
|
created_at=row.created_at,
|
|
like_count=like_count,
|
|
is_liked_by_me=is_liked,
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/{content_id}",
|
|
response_model=SsulDeleteResponse,
|
|
summary="썰박스 콘텐츠 소프트 삭제",
|
|
description="""
|
|
본인 소유의 썰박스 콘텐츠를 소프트 삭제합니다 (`is_deleted=True`, 데이터는 유지).
|
|
castad `DELETE /archive/videos/{video_id}` 와 동일한 정책입니다.
|
|
|
|
- 진행 중(queued/running)인 잡은 삭제할 수 없습니다 — 완료·실패 후에 지워야
|
|
워커·크레딧 환불 경로와 충돌하지 않습니다.
|
|
""",
|
|
responses={
|
|
200: {"description": "삭제 성공"},
|
|
401: {"description": "인증 실패"},
|
|
404: {"description": "콘텐츠를 찾을 수 없음 (남의 것 포함)"},
|
|
409: {"description": "진행 중인 잡은 삭제 불가"},
|
|
},
|
|
)
|
|
async def delete_content(
|
|
content_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> SsulDeleteResponse:
|
|
row = await session.get(SsulContent, content_id)
|
|
# 남의 것이면 존재 여부를 노출하지 않고 동일하게 404
|
|
if row is None or row.is_deleted or row.user_uuid != current_user.user_uuid:
|
|
raise TaskNotFoundError()
|
|
|
|
# 진행 중인 잡을 지우면 워커가 완료 시점에 삭제된 행을 done 으로 되살리거나,
|
|
# 실패 환불 경로와 꼬인다. 터미널 상태에서만 허용한다.
|
|
if row.status in ("queued", "running"):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="생성이 진행 중입니다. 완료된 뒤에 삭제해주세요.",
|
|
)
|
|
|
|
row.is_deleted = True
|
|
await session.commit()
|
|
logger.info(
|
|
f"[delete_content] id={content_id} user={current_user.user_uuid}"
|
|
)
|
|
return SsulDeleteResponse(
|
|
success=True,
|
|
content_id=content_id,
|
|
message="콘텐츠가 삭제되었습니다.",
|
|
)
|