o2o-castad-backend/app/ssulbox/api/routers/v1/content.py

195 lines
7.0 KiB
Python

# -*- coding: utf-8 -*-
"""썰박스 API — 장소 검색 · 생성 요청 · 진행 폴링.
castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `/ssul` 로 노출한다.
인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기).
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from app.credit.exceptions import InsufficientCreditError
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,
SsulPlaceSearchResponse,
SsulTaskStatus,
)
from app.ssulbox.services import place_service, task_service
from app.ssulbox.worker import job_manager
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.logger import get_logger
from config import ssulbox_settings
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("ssulbox")
router = APIRouter(prefix="/ssul", tags=["Ssulbox"])
@router.get(
"/search/place",
response_model=SsulPlaceSearchResponse,
summary="업장 검색 (네이버 지도)",
description="""
업장명으로 네이버 지도 후보를 검색합니다.
castad `/search/accommodation`(네이버 **검색 API**)과 달리 **`place_url` 을 포함**합니다.
생성 파이프라인이 네이버 지도 place 페이지를 크롤링하므로 이 URL 이 필요합니다.
- 동명 업장은 주소로 구분됩니다.
- 검색 실패·타임아웃 시 빈 목록을 반환합니다(사용자는 네이버 링크를 직접 붙여넣을 수 있습니다).
- Playwright 크롤링이라 수 초 걸립니다.
""",
responses={
200: {"description": "검색 성공 (결과 없음도 200)"},
401: {"description": "인증 실패"},
},
)
async def search_place(
query: str = Query(..., min_length=2, description="업장명"),
limit: int = Query(default=8, ge=1, le=20),
current_user: User = Depends(get_current_user),
) -> SsulPlaceSearchResponse:
items = await place_service.search_places(query, limit=limit)
return SsulPlaceSearchResponse(query=query, count=len(items), items=items)
@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 은 앱 이벤트 루프를 캡처하므로 반드시 요청 핸들러에서 호출한다.
job_manager.create_job(
content_id, body.scenario, body.input, body.scenes, body.seconds
)
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)