o2o-castad-backend/docs/design/p2v-credit-integration.md
2026-08-26 13:52:18 +09:00

415 lines
22 KiB
Markdown

# 📋 설계 문서 — P2V(무빙 포스터·포스터 스타일링) 크레딧 연동
작성일: 2026-08-25
대상 모듈: `app/p2v/` (신규)
---
## 1. 요구사항 요약
### 배경
프론트의 **무빙 포스터(F1)** 와 **포스터 스타일링(F2)** 은 castad 백엔드를 거치지 않고
별도 P2V 서버(`:8010`, `o2o-ado2-poster-to-video`)를 브라우저에서 **직접 호출**한다.
그 결과 castad의 크레딧 원장이 이 요청을 볼 수 없어 **과금이 전혀 되지 않는다.**
### 기능적 요구사항
| # | 내용 |
|---|---|
| FR-1 | F1·F2 생성 요청 시 크레딧을 **선차감**하고, 실패 시 **자동 환불**한다 |
| FR-2 | 잡의 **소유자**를 castad가 관리한다 (P2V 서버에는 사용자 개념이 없음) |
| FR-3 | 완성 **결과물을 Azure Blob에 업로드**하고 URL을 DB에 보관한다 |
| FR-4 | 프론트의 P2V 직접 호출을 castad 프록시 경유로 전환한다 |
| FR-5 | 파이프라인별로 결과 메타데이터를 보관해 P2V 없이도 이력 조회가 가능해야 한다 |
### 비기능적 요구사항
- P2V 서버는 **단일 워커** 전제 — castad가 동시 요청을 늘려도 P2V 처리량은 그대로다
- 크레딧 차감/환불은 **멱등**이어야 한다 (폴링 중복·재시도·크래시 복구)
- 저작권: 결과물이 공개 Blob URL에 올라가므로 **템플릿 라이선스 등급을 기록**한다
### 범위 밖 (명시적 제외)
- P2V 서버 자체 코드 수정 (콜백/웹훅 추가 등) — castad가 폴링으로 해결한다
- retry 재과금 정책 — **보류**. §9 참조
---
## 2. 설계 개요
```
브라우저
│ Authorization: Bearer <castad JWT>
▼
castad :8000 /p2v/*
├─ 인증(get_current_user) · 소유권 검사
├─ 크레딧 선차감/환불 (credit_transaction 원장 재사용)
├─ P2V API 중계 (httpx)
└─ 완료 감지 시 결과물 Blob 아카이빙 (BackgroundTasks)
│ X-P2V-Key
▼
P2V :8010 (Docker, 외부 비공개)
```
### 핵심 결정
1. **id 체계**: 프론트에는 castad 자체 id(BIGINT)만 노출한다. P2V 잡 id는 내부 컬럼에만 둔다.
소유권 검사가 castad id 기준으로 단순해지고, P2V 식별자가 외부로 새지 않는다.
2. **결과물은 Blob, 나머지는 경량 프록시**: mp4·결과 이미지는 Blob 공개 URL로 서빙하므로
Range 요청 스트리밍 프록시가 **불필요**하다. 검수용 `check_jpg`와 템플릿 썸네일만
작은 이미지 프록시로 처리한다.
3. **`archiving` 상태 도입**: P2V가 `done`을 보고해도 Blob 업로드 전까지는 `archiving`으로
응답한다. 프론트는 이를 진행 중으로 취급하므로 "URL이 잠깐 비어 있는" 창이 생기지 않는다.
4. **서비스는 commit 하지 않는다** — 트랜잭션 소유는 라우터/워커 (ssulbox 규칙 준수).
---
## 3. API 설계
`APIRouter(prefix="/p2v", tags=["P2V"])` — 전 엔드포인트 `Depends(get_current_user)`.
### F1 — 무빙 포스터
| 메서드 | 경로 | 요청 | 응답 | 크레딧 |
|---|---|---|---|---|
| POST | `/p2v/f1/jobs` | multipart(`poster`, `name`) | `P2vJobCreateResponse` | **선차감** |
| GET | `/p2v/f1/jobs/{job_id}` | — | `P2vF1StatusResponse` | — |
| PUT | `/p2v/f1/jobs/{job_id}/narration` | `NarrationUpdateRequest` | `204` | — |
| PUT | `/p2v/f1/jobs/{job_id}/metadata` | `MetadataUpdateRequest` | `204` | — |
| POST | `/p2v/f1/jobs/{job_id}/approve` | `ApproveRequest?` | `202` | — |
| POST | `/p2v/f1/jobs/{job_id}/retry` | `ApproveRequest?` | `202` | 보류(§9) |
| DELETE | `/p2v/f1/jobs/{job_id}` | — | `P2vDeleteResponse` | — |
### F2 — 포스터 스타일링
| 메서드 | 경로 | 요청 | 응답 | 크레딧 |
|---|---|---|---|---|
| GET | `/p2v/f2/templates` | — | `list[F2TemplateResponse]` | — |
| POST | `/p2v/f2/templates` | multipart(`reference`, `name`) | `F2TemplateResponse` | — |
| DELETE | `/p2v/f2/templates/{template_id}` | — | `P2vDeleteResponse` | — |
| GET | `/p2v/f2/categories` | — | `list[F2CategoryResponse]` | — |
| GET | `/p2v/f2/formats` | — | `list[F2FormatResponse]` | — |
| GET | `/p2v/f2/upload-hint` | — | `F2UploadHintResponse` | — |
| POST | `/p2v/f2/jobs` | multipart(`poster`, `template_id`, `format`) | `P2vJobCreateResponse` | **선차감** |
| GET | `/p2v/f2/jobs/{job_id}` | — | `P2vF2StatusResponse` | — |
### 정적 파일 경량 프록시
| 메서드 | 경로 | 용도 |
|---|---|---|
| GET | `/p2v/files/{path:path}` | 검수용 `check_jpg`, 템플릿 썸네일 |
- 허용 prefix **화이트리스트**: `files/templates/`, `files/user_templates/`, `files/regions/`
→ `files/render/`·`files/uploads/`는 **차단**(결과물은 Blob으로만 나간다)
- 경로 traversal 방어(`..` 거부), `Content-Type` 중계, Range 미지원(작은 이미지 전용)
### 공통 응답 코드
`401` 인증 실패 · `402` 크레딧 부족 · `404` 잡 없음/**남의 잡** · `503` P2V 비활성·연결 실패
> 소유권 위반은 403이 아니라 **404** — 존재 여부를 노출하지 않는 ssulbox 정책을 따른다.
---
## 4. 데이터 모델
### `app/p2v/models.py`
파이프라인별로 **2개 테이블**. 과정과 산출물이 다르고, P2V 없이도 이력이 완결되어야 한다.
#### `P2vF1Job` → `p2v_video` (클래스명은 파이프라인 코드명 F1을 유지, 테이블명은 산출물 종류를 따른다)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| `id` | BigInteger PK | 크레딧 원장 `job_ref` 앵커 |
| `user_uuid` | String(36) FK→user | 소유자 |
| `p2v_job_id` | String(64) UNIQUE NULL | P2V 잡 id. 서버 호출 성공 후 채움 |
| `name` | String(100) NULL | 사용자 입력 행사명 |
| `status` | String(20) | `queued/running/awaiting_review/archiving/done/failed` |
| `credit_amount` | Integer | 차감 크레딧 (환불 금액 결정) |
| `p2v_video_url` | String(500) NULL | 완성 영상 **Blob URL** (결과물) |
| `poster_url` | String(500) NULL | 썸네일 Blob URL (SNS 공유 og:image 역할 — `ssul_content.poster_url` 관례) |
| `source_image_url` | String(500) NULL | 원본 업로드 포스터 Blob URL |
| `event_name` / `date_text` / `place` | String | 검수 확정 메타데이터 |
| `keywords` / `narration` | JSON NULL | 키워드·나레이션 3문장 |
| `duration` | Decimal(6,2) NULL | 영상 길이(초) |
| `error` | String(1000) NULL | 실패 사유 |
| `archived_at` | DateTime NULL | Blob 업로드 완료 시각 |
| `created_at` / `updated_at` | DateTime | |
#### `P2vF2Job` → `p2v_poster`
| 컬럼 | 타입 | 설명 |
|---|---|---|
| `id` | BigInteger PK | 크레딧 원장 앵커 |
| `user_uuid` | String(36) FK→user | 소유자 |
| `p2v_job_id` | String(64) UNIQUE NULL | P2V 잡 id |
| `source_video_id` | BigInteger FK→p2v_video NULL | P2V `source_slug` 대응. 독립 잡이면 NULL |
| `status` | String(20) | `queued/running/archiving/done/failed` |
| `credit_amount` | Integer | 차감 크레딧 |
| `template_id` | String(64) | 사용 템플릿 |
| `template_name` | String(100) NULL | 이름 스냅샷 (템플릿 삭제 대비) |
| `license` | String(20) NULL | 배포 등급 — **외부 공개 가부 판단용** |
| `format` | String(16) | `poster/story/feed/square` |
| `p2v_poster_url` | String(500) NULL | 스타일 변환 결과 이미지 **Blob URL** (결과물) |
| `source_image_url` | String(500) NULL | 원본 업로드 포스터 Blob URL |
| `error` / `archived_at` / `created_at` / `updated_at` | | F1과 동일 |
### 크레딧 원장 — **스키마 변경 없음**
`credit_transaction`이 이미 `(job_type, job_ref, type)` 멱등 키로 이질적 작업을 수용한다.
`constants.py`에 상수만 추가:
```python
JOB_TYPE_P2V_F1: Final[str] = "p2v_f1"
JOB_TYPE_P2V_F2: Final[str] = "p2v_f2"
```
파이프라인별 가격 차등이 자연스럽게 가능하다
(F1은 Higgsfield 22크레딧+OpenAI 다수, F2는 gpt-image 1회로 실비용 차이가 크다).
### 마이그레이션
`docs/database-schema/migration_2026-08-25_p2v.sql` — **수동 실행**. 앱은 DDL을 실행하지 않는다.
`app/database/session.py`의 `create_db_tables()`에 모델 import + `__table__` 등록 필요.
---
## 5. 서비스 레이어
### `app/p2v/services/client.py` — P2V HTTP 클라이언트
- `httpx.AsyncClient` 모듈 싱글턴 (`app/utils/creatomate.py`의 `_shared_client` 패턴)
- 모든 요청에 `X-P2V-Key` 주입, timeout/limits 튜닝
- `createF2Template`은 서버가 **동기로 10초 안팎 화풍 분석**을 하므로 timeout ≥ 60s
- 연결 실패 → `P2vUnavailableError`(503)
### `app/p2v/services/f1_service.py` / `f2_service.py` — 잡 라이프사이클
**모듈 레벨 함수, commit 하지 않음** (ssulbox `task_service` 스타일)
```
create_job(session, *, user_uuid, ...) -> P2vF1Job
session.add(row)
await session.flush() # id 확보
await deduct_credit_for_job( # ← 같은 트랜잭션
session, user_uuid=..., amount=...,
job_type=JOB_TYPE_P2V_F1, job_ref=str(row.id), reason=...)
return row # commit 은 라우터가
sync_from_p2v(session, row, p2v_job) -> P2vF1Job
상태·메타데이터 미러링. failed 로 전이하면 refund_credit_for_job 호출(멱등)
fail_job(session, row, detail) # 환불 + status='failed'
sweep_orphans(session) # 오래된 awaiting_review/queued 환불 (lifespan 훅)
```
### `app/p2v/services/archive_service.py` — Blob 아카이빙
- `blob_enabled()` 가드 (ssulbox `blob_service` 복제)
- `AzureBlobUploader(user_uuid=..., task_id=f"p2v-f1-{id}")` → 경로가 castad 콘텐츠와 분리됨
- P2V에서 바이트로 받아 **로컬 파일 없이** `upload_video_bytes` / `upload_image_bytes`로 중계
- 성공 시 F1은 `p2v_video_url`/`poster_url`, F2는 `p2v_poster_url` + `archived_at` 기록, `status='done'`
### 트랜잭션 경계 (ssulbox 규칙 준수)
| 시점 | 세션 | 이유 |
|---|---|---|
| 생성(POST) | `AsyncSessionLocal()` **직접** | INSERT + 선차감을 한 트랜잭션으로 묶는다 |
| 그 외 라우터 | `Depends(get_session)` | 일반 규칙 |
| 아카이빙(BackgroundTasks) | `BackgroundSessionLocal()` | 요청 풀과 분리 |
**생성 순서 — 이 순서가 핵심이다:**
```
1. INSERT + flush + 크레딧 차감 ← 부족하면 402, P2V 미호출
2. commit ← 차감 확정
3. P2V API 호출 (파일 업로드)
4. p2v_job_id UPDATE + commit
```
크레딧 검사를 P2V 호출보다 **먼저** 하므로, 잔액이 없는 사용자가 OpenAI 실비용을
태우는 일이 없다. 3에서 실패하면 `fail_job`으로 즉시 환불한다.
### Blob 아카이빙 트리거
폴링 엔드포인트가 P2V `done`을 **처음 관측**했을 때 `BackgroundTasks`로 위임한다.
(짧은 async I/O이므로 job_manager 스레드 방식은 과하다 — 선택 기준 준수)
중복 실행 방지는 낙관적 잠금:
```sql
UPDATE p2v_video SET status='archiving'
WHERE id=:id AND status<>'archiving' AND archived_at IS NULL
```
`rowcount == 0`이면 다른 폴링이 이미 집어간 것이므로 조용히 넘어간다.
---
## 6. 스키마 (`app/p2v/schemas/p2v_schema.py`)
Pydantic v2. 요청은 `str | None`, 응답은 `Optional[...]`, 상태는 `Literal`로 좁힌다.
검증 범위·기본값은 **스키마에만** 두고 DB 모델에 중복하지 않는다.
```python
F1StatusLiteral = Literal["queued", "running", "awaiting_review", "archiving", "done", "failed"]
F2StatusLiteral = Literal["queued", "running", "archiving", "done", "failed"]
FormatLiteral = Literal["poster", "story", "feed", "square"]
class P2vJobCreateResponse(BaseModel):
"""생성 요청 접수. id 로 폴링한다."""
id: int = Field(..., description="castad 잡 ID (P2V 식별자가 아니다)")
status: F1StatusLiteral = Field(..., description="접수 직후 상태")
poll_interval_seconds: int = Field(..., description="권장 폴링 간격(초)")
class P2vF1StatusResponse(BaseModel):
id: int = Field(..., description="잡 ID")
status: F1StatusLiteral = Field(..., description="진행 상태")
stage: Optional[str] = Field(None, description="현재 스테이지")
progress: int = Field(0, ge=0, le=100, description="진행률")
narration: Optional[list[str]] = Field(None, description="나레이션 3문장 (검수 대상)")
metadata: Optional[P2vMetadata] = Field(None, description="행사 메타데이터 (검수 대상)")
motion_elements: Optional[list[str]] = Field(None, description="채택된 모션 요소")
p2v_video_url: Optional[str] = Field(None, description="완성 영상 Blob URL (done 에서만)")
poster_url: Optional[str] = Field(None, description="썸네일 Blob URL (SNS 공유 og:image 역할)")
check_image_url: Optional[str] = Field(None, description="검수용 영역분석 이미지 (프록시 경로)")
error: Optional[str] = Field(None, description="실패 사유")
# 필드명을 DB 컬럼명과 맞춰 from_attributes 자동 매핑이 깨지지 않게 한다.
model_config = {"from_attributes": True}
class NarrationUpdateRequest(BaseModel):
narration: list[str] = Field(..., min_length=1, max_length=5,
description="수정한 나레이션 문장 목록")
class MetadataUpdateRequest(BaseModel):
event_name: str = Field(..., max_length=200, description="행사명")
date_text: str = Field(default="", max_length=100, description="일시 표기")
place: str = Field(..., max_length=200, description="장소")
class F2JobCreateRequest(BaseModel):
"""multipart 이므로 실제로는 Form 파라미터. 검증 범위 문서화용."""
template_id: str = Field(..., max_length=64, description="스타일 템플릿 id")
format: FormatLiteral = Field(default="poster", description="출력 포맷")
```
---
## 7. 파일 구조
### 신규 — `app/p2v/`
```
app/p2v/
├── __init__.py 모듈 docstring
├── constants.py JOB_TYPE_P2V_F1/F2, 상태 Enum, 프록시 화이트리스트
├── exceptions.py P2vException + 하위 예외
├── models.py P2vF1Job, P2vF2Job
├── api/routers/v1/
│ ├── f1.py 무빙 포스터 라우터
│ ├── f2.py 스타일링 라우터
│ └── files.py 경량 정적 프록시
├── schemas/p2v_schema.py 요청/응답 DTO
└── services/
├── client.py P2V httpx 싱글턴
├── f1_service.py F1 잡 라이프사이클 + 크레딧
├── f2_service.py F2 잡 라이프사이클 + 크레딧
└── archive_service.py Blob 업로드
```
(각 `__init__.py`는 빈 파일 — 재수출하지 않는다)
### 수정
| 파일 | 작업 |
|---|---|
| `config.py` | `P2vSettings` 추가 + 파일 끝 `p2v_settings = P2vSettings()` |
| `app/core/exceptions.py` | `add_exception_handlers()`에 `@app.exception_handler(P2vException)` 블록 추가 |
| `app/database/session.py` | `create_db_tables()`에 모델 import + `__table__` 등록 |
| `app/core/common.py` | lifespan에 `sweep_orphans` 훅 (ssulbox 패턴) |
| `main.py` | 라우터 import + `tags_metadata` 엔트리 + `if p2v_settings.P2V_ENABLED:` 조건부 등록 |
| `docs/database-schema/migration_2026-08-25_p2v.sql` | DDL (수동 실행) |
### `P2vSettings` 필드 (env_prefix 대신 필드명에 접두)
```
P2V_ENABLED bool 기본 True
P2V_BASE_URL str 기본 http://localhost:8010
P2V_ACCESS_KEY str 기본 "" (P2V 서버 X-P2V-Key)
P2V_CREDITS_PER_F1 int 기본 ? ← 가격 미정
P2V_CREDITS_PER_F2 int 기본 ? ← 가격 미정
P2V_BLOB_PREFIX str 기본 "p2v"
P2V_POLL_HINT_SECONDS int 기본 3
P2V_ORPHAN_TIMEOUT_HOURS int 기본 24 (검수 방치 잡 환불 기준)
```
### 프론트엔드 (별도 저장소)
| 파일 | 작업 |
|---|---|
| `src/utils/p2vApi.ts` | **핵심 교체 지점** — `P2V_URL`→castad `/p2v`, `p2vFetch`→`authenticatedFetch`, `p2vFile()` 재작성, 402 처리 추가, 키 저장 함수 제거 |
| `src/components/P2vKeyGate.tsx` | **삭제** (castad JWT가 대체) |
| `PosterCreateForm.tsx` / `StylingContent.tsx` | `P2vAuthError` 분기 → 402 크레딧 부족 UI |
| `PosterResultContent.tsx` / `PosterReviewContent.tsx` | `p2vFile` 사용처가 Blob URL·프록시 경로로 바뀜 |
| `useP2vJob.ts` | `authNeeded` 의미 변경 |
---
## 8. 구현 순서
1. **마이그레이션 SQL 작성** → 사용자가 수동 실행 (앱은 DDL 미실행)
2. `config.py`에 `P2vSettings` + 인스턴스
3. `app/p2v/` 뼈대: `__init__/constants/exceptions/models`
4. `app/core/exceptions.py`에 `P2vException` 핸들러 등록
5. `app/database/session.py`에 모델 등록
6. `services/client.py` — P2V 중계 클라이언트 (가장 먼저 단독 검증 가능)
7. `schemas/p2v_schema.py`
8. `services/f1_service.py` / `f2_service.py` — 크레딧 차감·환불·상태 미러
9. `services/archive_service.py` — Blob 업로드
10. `api/routers/v1/f2.py` — **F2를 먼저** (단일 단계라 흐름이 단순, 검증이 빠름)
11. `api/routers/v1/f1.py` — 검수 게이트 포함
12. `api/routers/v1/files.py` — 경량 프록시
13. `main.py` 등록 + `tags_metadata`
14. `app/core/common.py` lifespan 고아 스윕
15. 프론트 `p2vApi.ts` 교체 → 나머지 컴포넌트
> **10번을 F2부터** 하는 이유: F1은 7단계 파이프라인 + 검수 게이트라 왕복이 길다.
> F2로 "인증→차감→프록시→Blob→환불" 전 경로를 먼저 관통시켜 검증한 뒤 F1에 적용한다.
---
## 9. 설계 검수 결과
### 체크리스트
| 항목 | 결과 |
|---|---|
| 기존 프로젝트 패턴과 일관성 | ✅ ssulbox 구조·예외(B계열)·트랜잭션 경계·Blob 재사용 |
| 비동기 처리 적절성 | ✅ httpx AsyncClient, BackgroundTasks, BackgroundSessionLocal |
| N+1 쿼리 | ✅ 단건 조회 위주. 목록은 단일 `select` + `scalars().all()` |
| 트랜잭션 경계 명확성 | ✅ 서비스는 commit 안 함, 생성만 `AsyncSessionLocal()` 직접 |
| 예외 처리 전략 | ✅ `P2vException(message, status_code, code)` + 전역 핸들러 |
| 확장성 | ✅ 파이프라인 추가 시 테이블·서비스만 추가. 원장은 무변경 |
| 직관적 구조 | ✅ F1/F2 라우터 분리로 파이프라인 차이가 코드에 드러남 |
| SOLID | ✅ client/service/archive 책임 분리 |
### 발견된 위험과 대응
| # | 위험 | 대응 |
|---|---|---|
| R-1 | **검수 방치 시 크레딧 증발** — 업로드 후 approve 안 하면 선차감분이 안 돌아옴 | lifespan `sweep_orphans`가 `P2V_ORPHAN_TIMEOUT_HOURS` 초과 `awaiting_review` 잡 환불 |
| R-2 | **게이트 실패 시 실비용 손실** — 제목 훼손 게이트는 Higgsfield 크레딧이 나간 **뒤** 실패한다. 유저 환불 시 실비용은 회사 부담 | 정책상 수용. 손실 추적을 위해 `error`에 사유 보존 |
| R-3 | 폴링 중복으로 Blob 이중 업로드 | `status='archiving'` 낙관적 잠금 (rowcount 검사) |
| R-4 | P2V 단일 워커 — castad 동시성이 늘어도 처리량 불변 | `queue_size` 를 응답에 노출해 대기열 안내 |
| R-5 | 사용자 업로드 레퍼런스는 권리 미확인 | `license` 컬럼에 등급 스냅샷 보존 |
| R-6 | P2V 컨테이너 재생성 시 `/app/prompts` 소실 | P2V `docker-compose.yml`에 `p2v-prompts` 볼륨 추가 권장 (범위 밖, 별도 조치) |
### 미결 사항 — **구현 착수 전 확정 필요**
| # | 항목 | 영향 |
|---|---|---|
| D-1 | **F1 차감 시점** — 잡 생성 vs approve(검수 승인) | 본 설계는 ssulbox 선례를 따라 **생성 시점**으로 잡고 R-1 스윕으로 보완했다. approve 시점으로 바꾸면 스윕이 불필요해지는 대신 검수 전 무료 구간이 생긴다 |
| D-2 | **retry 재과금** — 보류 중 | 재과금 시 `attempt INT NOT NULL DEFAULT 1` 컬럼 추가 + `job_ref = "{id}:{attempt}"`. **순수 추가형 ALTER**라 나중에 결정해도 비파괴적. 미결 동안은 재시도 무료가 기본 동작 |
| D-3 | **크레딧 단가** — `P2V_CREDITS_PER_F1/F2` | 실비용 차이가 크다(F1: Higgsfield 22크레딧+OpenAI 다수 / F2: gpt-image 1회) |
---
## 10. 구현 노트 (2026-08-25 /develop 반영)
설계와 달라진 두 가지 — 구현 중 발견한 근거로 조정했다:
1. **응답 스키마는 P2V 호환 형태(artifacts 딕셔너리)를 유지했다.** §6 초안의
`p2v_video_url` 평면 필드 대신, 프론트가 이미 맞춰져 있는 P2V 잡 JSON 형태
(`artifacts.video`, `stages`, `queue_size`)를 유지해 프론트 수정을 3개 파일로
줄였다. DB 컬럼명(§4)은 초안대로다 — API 표현과 저장 표현을 분리한 것.
2. **F1 은 실패 관측 시 환불하지 않는다.** 크레딧 원장의 (job_type, job_ref, type)
유니크 제약상 환불 후 재차감이 불가능해, "실패 → 자동 환불 → 무료 재시도"가
되는 구멍이 있었다. F1 환불 시점은 **삭제·고아 스윕·P2V 잡 소실** 세 경우로
한정한다. F2 는 재시도 경로가 없으므로 초안대로 실패 즉시 환불한다.
## 다음 단계
`/review` 로 코드 리뷰를 진행한다. 배포 전 수동 작업:
- `migration_2026-08-25_p2v.sql` 수동 실행 (DDL 은 앱이 실행하지 않는다)
- castad 백엔드가 컨테이너로 뜨는 환경에서는 `.env` 에
`P2V_BASE_URL=http://host.docker.internal:8010` 지정 (기본값 localhost 는
컨테이너 자신을 가리킨다). P2V 포트는 호스트 127.0.0.1 에만 바인딩돼 있다.
- 크레딧 단가(D-3) 확정 시 `P2V_CREDITS_PER_F1/F2` 설정