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

22 KiB

📋 설계 문서 — 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 없이도 이력이 완결되어야 한다.

P2vF1Jobp2v_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

P2vF2Jobp2v_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에 상수만 추가:

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.pycreate_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 스레드 방식은 과하다 — 선택 기준 준수)

중복 실행 방지는 낙관적 잠금:

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 모델에 중복하지 않는다.

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, p2vFetchauthenticatedFetch, 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.pyP2vSettings + 인스턴스
  3. app/p2v/ 뼈대: __init__/constants/exceptions/models
  4. app/core/exceptions.pyP2vException 핸들러 등록
  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.pyF2를 먼저 (단일 단계라 흐름이 단순, 검증이 빠름)
  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_orphansP2V_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.ymlp2v-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 백엔드가 컨테이너로 뜨는 환경에서는 .envP2V_BASE_URL=http://host.docker.internal:8010 지정 (기본값 localhost 는 컨테이너 자신을 가리킨다). P2V 포트는 호스트 127.0.0.1 에만 바인딩돼 있다.
  • 크레딧 단가(D-3) 확정 시 P2V_CREDITS_PER_F1/F2 설정