# -*- coding: utf-8 -*- """썰박스 API — 장소 검색 · 생성 요청 · 진행 폴링. castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `/ssul` 로 노출한다. 인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기). """ from fastapi import APIRouter, Depends, HTTPException 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.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.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( "/{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, 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="콘텐츠가 삭제되었습니다.", )