o2o-castad-backend/app/p2v/api/routers/v1/f1.py
2026-08-26 13:52:18 +09:00

430 lines
15 KiB
Python

# -*- coding: utf-8 -*-
"""P2V F1(무빙 포스터) 프록시 라우터.
생성 → 폴링 → (검수: 나레이션/메타 수정 → 승인) → 폴링 → done 의 2단 게이트.
크레딧은 생성 시점 선차감. 실패 시 즉시 환불하지 않는다 — 재시도 버튼이 있어
"환불 + 공짜 재시도"가 되기 때문이다. 환불은 삭제·고아 스윕에서 일어난다
(f1_service 모듈 docstring 참조).
"""
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.credit.exceptions import InsufficientCreditError
from app.database.session import AsyncSessionLocal, get_session
from app.p2v.exceptions import (
P2vDisabledError,
P2vException,
P2vInvalidStateError,
P2vUploadTooLargeError,
P2vUpstreamError,
)
from app.p2v.models import P2vF1Job
from app.p2v.schemas.p2v_schema import (
ApproveRequest,
MetadataUpdateRequest,
NarrationUpdateRequest,
P2vDeleteResponse,
P2vF1StatusResponse,
P2vJobCreateResponse,
P2vJobError,
P2vMetadata,
)
from app.p2v.services import archive_service, client, f1_service
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 p2v_settings
logger = get_logger("p2v")
router = APIRouter(prefix="/p2v/f1", tags=["P2V"])
def _guard_enabled() -> None:
if not p2v_settings.P2V_ENABLED:
raise P2vDisabledError()
@router.post(
"/jobs",
response_model=P2vJobCreateResponse,
summary="무빙 포스터 생성 요청",
description="**이 시점에 크레딧이 선차감됩니다.** 검수 승인 없이 방치하면 "
"일정 시간 후 자동 환불·실패 처리됩니다.",
responses={
401: {"description": "인증 실패"},
402: {"description": "크레딧 부족"},
413: {"description": "용량 초과"},
503: {"description": "P2V 서버 연결 실패"},
},
)
async def create_job(
background_tasks: BackgroundTasks,
poster: UploadFile = File(...),
name: str = Form(""),
current_user: User = Depends(get_current_user),
) -> P2vJobCreateResponse:
_guard_enabled()
raw = await poster.read()
if len(raw) > p2v_settings.P2V_MAX_UPLOAD_BYTES:
raise P2vUploadTooLargeError(p2v_settings.P2V_MAX_UPLOAD_BYTES)
# 행 삽입 + 크레딧 선차감을 한 트랜잭션으로 묶는다 (ssulbox create_ssul 패턴)
async with AsyncSessionLocal() as session:
try:
row = await f1_service.create_job(
session, user_uuid=current_user.user_uuid, name=name.strip()
)
await session.commit()
job_id = row.id
except InsufficientCreditError:
await session.rollback()
logger.info(f"[f1] INSUFFICIENT CREDIT user={current_user.user_uuid}")
raise
except Exception:
await session.rollback()
raise
# 차감이 확정된 뒤에야 P2V 를 부른다
try:
created = await client.request_json(
"POST",
"/api/f1/jobs",
data={"name": name},
files={
"poster": (
poster.filename or "poster.png",
raw,
poster.content_type or "image/png",
)
},
)
except P2vException as e:
# P2V 를 태우지 못했다(실비용 없음) — 환불하고 실패로 남긴다
async with AsyncSessionLocal() as session:
failed_row = await session.get(P2vF1Job, job_id)
if failed_row is not None:
await f1_service.fail_job(
session, failed_row, f"P2V 요청 실패: {e.message}", refund=True
)
await session.commit()
raise
async with AsyncSessionLocal() as session:
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
row.p2v_job_id = str(created["id"])
await session.commit()
background_tasks.add_task(
archive_service.upload_source_image,
"f1",
job_id,
current_user.user_uuid,
raw,
poster.filename or "poster.png",
)
return P2vJobCreateResponse(
id=job_id,
status="queued",
poll_interval_seconds=p2v_settings.P2V_POLL_HINT_SECONDS,
)
def _error_of(row: P2vF1Job) -> Optional[P2vJobError]:
if row.status != "failed" or not row.error:
return None
stage, _, detail = row.error.partition(": ")
return P2vJobError(stage=stage or "?", detail=detail or row.error)
def _metadata_of(row: P2vF1Job) -> Optional[P2vMetadata]:
if not (row.event_name or row.place or row.date_text):
return None
return P2vMetadata(
event_name=row.event_name or "",
date_text=row.date_text or "",
place=row.place or "",
keywords=row.keywords,
)
def _db_response(row: P2vF1Job) -> P2vF1StatusResponse:
"""P2V 를 부르지 않고 castad DB 미러만으로 응답 (archived/failed 종결 상태)."""
artifacts: dict[str, str] = {}
if row.p2v_video_url:
artifacts["video"] = row.p2v_video_url
if row.poster_url:
artifacts["thumbnail"] = row.poster_url
return P2vF1StatusResponse(
id=row.id,
status=row.status, # type: ignore[arg-type]
name=row.name,
narration=row.narration,
metadata=_metadata_of(row),
duration=float(row.duration) if row.duration is not None else None,
error=_error_of(row),
artifacts=artifacts,
)
@router.get(
"/jobs/{job_id}",
response_model=P2vF1StatusResponse,
summary="생성 진행 상태 (폴링)",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def get_job(
job_id: int,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> P2vF1StatusResponse:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
# 종결 상태 — P2V 를 부르지 않는다 (컨테이너 재기동으로 잡이 지워졌을 수 있다)
if row.archived_at is not None or row.status == "failed" or not row.p2v_job_id:
return _db_response(row)
try:
p2v_job = await client.request_json("GET", f"/api/f1/jobs/{row.p2v_job_id}")
except P2vUpstreamError as e:
if e.status_code == 404:
# P2V 재기동으로 잡 소실 — 재시도가 불가능하므로 환불하고 실패 처리
await f1_service.fail_job(
session,
row,
"서버 재시작으로 작업이 유실되었습니다. 크레딧을 환불했습니다",
refund=True,
)
await session.commit()
return _db_response(row)
raise
f1_service.sync_from_p2v(row, p2v_job)
status = row.status
if p2v_job.get("status") == "done" and row.archived_at is None:
# Blob 업로드 전까지는 진행 중으로 보인다 — URL 없는 done 창을 만들지 않는다
status = "archiving"
background_tasks.add_task(archive_service.archive_f1, row.id)
await session.commit()
# 검수용 영역분석 이미지는 프록시 경로로 재작성한다 (regions/ 는 인증 프록시)
artifacts: dict[str, str] = {}
check = (p2v_job.get("artifacts") or {}).get("check_jpg")
if check and check.startswith("/files/"):
artifacts["check_jpg"] = "/p2v" + check
return P2vF1StatusResponse(
id=row.id,
status=status, # type: ignore[arg-type]
stage=p2v_job.get("stage"),
stages=p2v_job.get("stages"),
name=row.name,
narration=row.narration,
metadata=_metadata_of(row),
motion_elements=p2v_job.get("motion_elements"),
duration=float(row.duration) if row.duration is not None else None,
queue_size=p2v_job.get("queue_size"),
error=_error_of(row),
artifacts=artifacts,
)
# =============================================================================
# 검수 게이트
# =============================================================================
@router.put(
"/jobs/{job_id}/narration",
summary="검수 중 나레이션 수정",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def update_narration(
job_id: int,
body: NarrationUpdateRequest,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
if not row.p2v_job_id:
raise P2vInvalidStateError("아직 서버에 접수되지 않은 작업입니다")
await client.request_json(
"PUT",
f"/api/f1/jobs/{row.p2v_job_id}/narration",
json_body={"narration": body.narration},
)
row.narration = body.narration
await session.commit()
return {"ok": True}
@router.put(
"/jobs/{job_id}/metadata",
summary="검수 중 행사 메타데이터 수정",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def update_metadata(
job_id: int,
body: MetadataUpdateRequest,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
if not row.p2v_job_id:
raise P2vInvalidStateError("아직 서버에 접수되지 않은 작업입니다")
await client.request_json(
"PUT",
f"/api/f1/jobs/{row.p2v_job_id}/metadata",
json_body=body.model_dump(),
)
row.event_name = body.event_name[:200]
row.date_text = body.date_text[:100] or None
row.place = body.place[:200]
await session.commit()
return {"ok": True}
@router.post(
"/jobs/{job_id}/approve",
summary="검수 승인 — TTS·BGM·애니메이션·렌더 시작",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def approve_job(
job_id: int,
body: Optional[ApproveRequest] = None,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
if not row.p2v_job_id:
raise P2vInvalidStateError("아직 서버에 접수되지 않은 작업입니다")
json_body = (
{"motions": body.motions} if body is not None and body.motions is not None else None
)
await client.request_json(
"POST", f"/api/f1/jobs/{row.p2v_job_id}/approve", json_body=json_body
)
row.status = "running"
await session.commit()
return {"ok": True}
@router.post(
"/jobs/{job_id}/retry",
summary="실패한 스테이지부터 재시도",
description="선차감분이 그대로 유효하므로 재시도에 크레딧이 추가 차감되지 않는다. "
"(재과금 정책은 D-2 로 보류 — 도입 시 attempt 컬럼 추가)",
responses={
401: {"description": "인증 실패"},
404: {"description": "잡 없음"},
409: {"description": "실패 상태가 아님"},
},
)
async def retry_job(
job_id: int,
body: Optional[ApproveRequest] = None,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
if row.status != "failed":
raise P2vInvalidStateError(f"실패 상태가 아닙니다: {row.status}")
if not row.p2v_job_id:
raise P2vInvalidStateError("서버에 접수되지 못한 작업은 재시도할 수 없습니다")
json_body = (
{"motions": body.motions} if body is not None and body.motions is not None else None
)
await client.request_json(
"POST", f"/api/f1/jobs/{row.p2v_job_id}/retry", json_body=json_body
)
row.status = "queued"
row.error = None
await session.commit()
return {"ok": True}
@router.post(
"/jobs/{job_id}/force",
summary="실패를 무시하고 진행 (i2v 게이트 실패 전용)",
description="제목 훼손 게이트 등 클립 생성 **이후의** 실패를 사람이 확인하고 "
"이미 만들어진 클립으로 렌더를 잇는다. 재시도와 달리 Higgsfield 재생성이 없다. "
"클립이 없는 실패는 P2V 가 409 로 거절한다.",
responses={
401: {"description": "인증 실패"},
404: {"description": "잡 없음"},
409: {"description": "실패 상태가 아니거나 무시할 수 없는 실패"},
},
)
async def force_job(
job_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
if row.status != "failed":
raise P2vInvalidStateError(f"실패 상태가 아닙니다: {row.status}")
if not row.p2v_job_id:
raise P2vInvalidStateError("서버에 접수되지 못한 작업은 진행할 수 없습니다")
await client.request_json("POST", f"/api/f1/jobs/{row.p2v_job_id}/force")
row.status = "queued"
row.error = None
await session.commit()
return {"ok": True}
@router.delete(
"/jobs/{job_id}",
response_model=P2vDeleteResponse,
summary="잡과 산출물 삭제 — 되돌릴 수 없다",
description="완성(done) 전에 버리는 잡은 이 시점에 크레딧이 환불된다.",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def delete_job(
job_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> P2vDeleteResponse:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
# P2V 쪽 산출물 먼저 정리. 이미 사라진 잡(404)은 정상 — 지울 게 없다.
if row.p2v_job_id:
try:
await client.request_json("DELETE", f"/api/f1/jobs/{row.p2v_job_id}")
except P2vUpstreamError as e:
if e.status_code != 404:
raise # 진행 중(409) 등은 그대로 중계 — 반쪽 삭제를 만들지 않는다
# 환불 판정은 잠금 재조회로 한다 (2026-08-26 리뷰 TOCTOU 반영) —
# 처음 읽은 스냅샷과 달리, 그 사이 백그라운드 아카이빙이 done 으로 전이를
# 커밋했을 수 있다. FOR UPDATE 로 최신 상태를 고정한 뒤 판정해야
# "완성본도 받고 환불도 받는" 창이 닫힌다.
result = await session.execute(
select(P2vF1Job)
.where(P2vF1Job.id == job_id)
.with_for_update()
)
row = result.scalar_one_or_none()
if row is None:
return P2vDeleteResponse(id=job_id, removed=True)
# done(완성)·archiving(렌더 완료, 업로드 중)은 결과물이 이미 만들어졌다 — 환불 없음
if row.archived_at is None and row.status not in ("done", "archiving"):
await f1_service.refund_job(session, row, reason="무빙 포스터 삭제(미완성)")
await session.delete(row)
await session.commit()
return P2vDeleteResponse(id=job_id, removed=True)