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

336 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""P2V F2(포스터 스타일링) 프록시 라우터.
브라우저 → castad(인증·크레딧) → P2V 서버(:8010) 중계.
템플릿 목록 등 조회는 순수 중계, 잡 생성만 크레딧 선차감이 붙는다.
"""
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, UploadFile
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,
P2vUploadTooLargeError,
P2vUpstreamError,
)
from app.p2v.models import P2vF2Job
from app.p2v.schemas.p2v_schema import (
F2CategoryResponse,
F2FormatResponse,
F2TemplateResponse,
F2UploadHintResponse,
P2vF2StatusResponse,
P2vJobCreateResponse,
P2vJobError,
)
from app.p2v.services import archive_service, client, f2_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/f2", tags=["P2V"])
def _guard_enabled() -> None:
if not p2v_settings.P2V_ENABLED:
raise P2vDisabledError()
def _proxy_thumb(url: str) -> str:
"""P2V 의 /files/... 경로를 castad 프록시 경로로 재작성한다."""
if url.startswith("/files/"):
return "/p2v" + url
return url
def _rewrite_template(t: dict) -> dict:
return {**t, "thumb_url": _proxy_thumb(t.get("thumb_url", ""))}
# =============================================================================
# 템플릿 · 메타 (순수 중계)
# =============================================================================
@router.get(
"/templates",
response_model=list[F2TemplateResponse],
summary="스타일 템플릿 목록",
responses={401: {"description": "인증 실패"}, 503: {"description": "P2V 서버 연결 실패"}},
)
async def list_templates(
current_user: User = Depends(get_current_user),
) -> list[dict]:
_guard_enabled()
items = await client.request_json("GET", "/api/f2/templates")
return [_rewrite_template(t) for t in items]
@router.post(
"/templates",
response_model=F2TemplateResponse,
summary="사용자 레퍼런스 업로드",
description="저장 + 화풍 분석까지 P2V 가 동기로 끝낸다(10초 안팎).",
responses={401: {"description": "인증 실패"}, 413: {"description": "용량 초과"}},
)
async def create_template(
reference: UploadFile = File(...),
name: str = Form(""),
current_user: User = Depends(get_current_user),
) -> dict:
_guard_enabled()
raw = await reference.read()
if len(raw) > p2v_settings.P2V_MAX_UPLOAD_BYTES:
raise P2vUploadTooLargeError(p2v_settings.P2V_MAX_UPLOAD_BYTES)
created = await client.request_json(
"POST",
"/api/f2/templates",
data={"name": name},
files={
"reference": (
reference.filename or "reference.png",
raw,
reference.content_type or "image/png",
)
},
)
return _rewrite_template(created)
@router.delete(
"/templates/{template_id}",
summary="사용자 레퍼런스 삭제",
responses={401: {"description": "인증 실패"}, 404: {"description": "삭제 불가 템플릿"}},
)
async def delete_template(
template_id: str,
current_user: User = Depends(get_current_user),
) -> dict:
_guard_enabled()
return await client.request_json("DELETE", f"/api/f2/templates/{template_id}")
@router.get("/categories", response_model=list[F2CategoryResponse], summary="템플릿 카테고리")
async def list_categories(current_user: User = Depends(get_current_user)) -> list[dict]:
_guard_enabled()
return await client.request_json("GET", "/api/f2/categories")
@router.get("/formats", response_model=list[F2FormatResponse], summary="출력 포맷 목록")
async def list_formats(current_user: User = Depends(get_current_user)) -> list[dict]:
_guard_enabled()
return await client.request_json("GET", "/api/f2/formats")
@router.get("/upload-hint", response_model=F2UploadHintResponse, summary="레퍼런스 업로드 안내")
async def upload_hint(current_user: User = Depends(get_current_user)) -> dict:
_guard_enabled()
return await client.request_json("GET", "/api/f2/upload-hint")
# =============================================================================
# 잡
# =============================================================================
@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(...),
template_id: str = Form(...),
format: str = Form("poster"),
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)
# 템플릿 스냅샷은 차감 **전에** 확보한다 — 없는 템플릿이면 크레딧을 건드리지 않는다
templates = await client.request_json("GET", "/api/f2/templates")
tpl = next((t for t in templates if t.get("id") == template_id), None)
if tpl is None:
raise P2vUpstreamError("존재하지 않는 템플릿입니다", 404)
# 포스터 이름은 업로드 파일명에서 딴다 (F2 에는 F1 의 행사명 같은 입력이 없다).
# 템플릿 이름을 이름 자리에 쓰면 "별이 빛나는 밤"이 포스터 이름처럼 보인다.
poster_name = (poster.filename or "").rsplit(".", 1)[0].strip()
# 행 삽입 + 크레딧 선차감을 한 트랜잭션으로 묶는다 (ssulbox create_ssul 패턴)
async with AsyncSessionLocal() as session:
try:
row = await f2_service.create_job(
session,
user_uuid=current_user.user_uuid,
name=poster_name,
template_id=template_id,
template_name=tpl.get("name_ko"),
license=tpl.get("license"),
format=format,
)
await session.commit()
job_id = row.id
except InsufficientCreditError:
await session.rollback()
logger.info(f"[f2] INSUFFICIENT CREDIT user={current_user.user_uuid}")
raise
except Exception:
await session.rollback()
raise
# 차감이 확정된 뒤에야 P2V 를 부른다 — 잔액 없는 사용자가 실비용을 태우지 않게
try:
created = await client.request_json(
"POST",
"/api/f2/jobs",
data={"template_id": template_id, "format": format},
files={
"poster": (
poster.filename or "poster.png",
raw,
poster.content_type or "image/png",
)
},
)
except P2vException as e:
# 차감은 이미 확정됐다 — 새 트랜잭션에서 실패 처리(환불)한다
async with AsyncSessionLocal() as session:
failed_row = await session.get(P2vF2Job, job_id)
if failed_row is not None:
await f2_service.fail_job(
session, failed_row, f"P2V 요청 실패: {e.message}"
)
await session.commit()
raise
async with AsyncSessionLocal() as session:
row = await f2_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,
"f2",
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,
)
@router.delete(
"/jobs/{job_id}",
summary="스타일링 잡 삭제 (내 콘텐츠 목록에서 제거)",
description="완성(done) 전에 버리는 잡은 이 시점에 크레딧이 환불된다. "
"P2V 서버에는 F2 잡 삭제 API 가 없어 castad 기록만 지운다 "
"(P2V 쪽 산출물은 서버 자체 보존 주기를 따른다).",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def delete_job(
job_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f2_service.get_owned(session, job_id, current_user.user_uuid)
# 완성본을 이미 받았다면 환불 대상이 아니다 (실패분은 fail_job 이 이미 환불했고,
# refund 는 멱등이라 중복 호출돼도 안전하다)
if row.archived_at is None and row.status not in ("done", "archiving"):
await f2_service.refund_job(session, row, reason="포스터 스타일링 삭제(미완성)")
await session.delete(row)
await session.commit()
return {"id": job_id, "removed": True}
def _error_of(row) -> P2vJobError | None:
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)
@router.get(
"/jobs/{job_id}",
response_model=P2vF2StatusResponse,
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),
) -> P2vF2StatusResponse:
_guard_enabled()
row = await f2_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:
artifacts = {}
if row.p2v_poster_url:
artifacts["image"] = row.p2v_poster_url
return P2vF2StatusResponse(
id=row.id,
status=row.status, # type: ignore[arg-type]
template_id=row.template_id,
error=_error_of(row),
artifacts=artifacts,
)
try:
p2v_job = await client.request_json("GET", f"/api/f2/jobs/{row.p2v_job_id}")
except P2vUpstreamError as e:
if e.status_code == 404:
# P2V 재기동 등으로 잡 소실 — 결과를 받을 길이 없으니 실패·환불
await f2_service.fail_job(
session, row, "서버 재시작으로 작업이 유실되었습니다. 크레딧을 환불했습니다"
)
await session.commit()
return P2vF2StatusResponse(
id=row.id, status="failed", template_id=row.template_id,
error=_error_of(row), artifacts={},
)
raise
await f2_service.sync_from_p2v(session, 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_f2, row.id)
await session.commit()
return P2vF2StatusResponse(
id=row.id,
status=status, # type: ignore[arg-type]
stage=p2v_job.get("stage"),
stages=p2v_job.get("stages"),
template_id=row.template_id,
queue_size=p2v_job.get("queue_size"),
error=_error_of(row),
artifacts={}, # 결과 이미지는 아카이브 완료 후에만 내려간다
)