336 lines
12 KiB
Python
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={}, # 결과 이미지는 아카이브 완료 후에만 내려간다
|
|
)
|