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