"""썰박스 SQLAlchemy 모델. castad 와 겹치는 테이블은 **신설하지 않고** castad 것을 그대로 쓴다 (user / credit_transaction / social_account, 그리고 2026-07-30 부터 comment / video_reaction). 그 테이블들은 `video_id` 와 `content_id` 를 모두 nullable 로 두고 **정확히 하나만** 채우도록 CHECK 로 강제한다. 썰박스 고유 도메인(`ssul_content`)만 `ssul_` 접두로 남는다. 컨벤션은 castad 를 따른다: BigInteger PK, user_uuid(String36) 기준 FK, mysql_engine/charset/collate 명시, 컬럼마다 comment. `updated_at` 은 대응 castad 테이블이 가진 경우에만 둔다 — `video`/`comment`/`project`/ `lyric`/`song` 은 상태 전이를 겪으면서도 `created_at` 만 갖고, `social_upload` 만 예외다. 주의: Alembic 이 없고 **DB 변경은 전부 수동**이 방침이다. 이 파일을 고쳐도 앱은 어떤 DDL 도 실행하지 않는다 — 대응 SQL 을 docs/database-schema/ 에 추가하고 배포 전에 직접 실행해야 운영 DB 에 반영된다. (자동 마이그레이션은 2026-07-30 제거) """ from datetime import datetime from typing import TYPE_CHECKING, Optional from sqlalchemy import ( BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func, ) from sqlalchemy.dialects.mysql import JSON from sqlalchemy.orm import Mapped, mapped_column from app.database.session import Base if TYPE_CHECKING: pass # MySQL 전용 테이블 옵션 (castad 공통) _MYSQL_OPTS = { "mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci", } class SsulContent(Base): """썰박스 1편 — 생성 요청부터 완성까지 한 행으로 관리한다. **castad `Video` 와 같은 구조다.** castad 도 "영상 생성 잡"과 "완성된 영상"을 나누지 않고 `video` 한 테이블에 status / result_movie_url 을 함께 둔다. 원본 썰박스는 Task 와 Content 를 나눴지만, 1:1 이면서 목록의 필터(user_uuid, scenario)와 정렬(created_at)이 서로 다른 테이블에 흩어져 조인 비용이 컸다 (측정: 소유 비율에 따라 18~22ms, 병합 시 1ms 수준). 라이프사이클: 1. 요청 → INSERT (status=queued, video_url=NULL) + 크레딧 선차감 2. 엔진 실행 → UPDATE status/step 3. 완료 → UPDATE video_url/store_name/region, status=done 4. 실패 → UPDATE status=error, error, 크레딧 환불 3번이 INSERT 가 아니라 UPDATE 라 finalize 가 자연히 멱등이다. 목록 조회는 castad `/video/all` 과 동일하게 완성분만 거른다: WHERE is_deleted=0 AND status='done' AND video_url IS NOT NULL id 가 크레딧 원장 멱등 키(job_type='ssul', job_ref=str(id))의 앵커다. """ __tablename__ = "ssul_content" __table_args__ = ( # 필터와 정렬이 같은 테이블에 있으므로 복합 인덱스 하나로 filesort 없이 처리된다. # 전체 목록 (완성분만, 최신순) Index("idx_ssul_content_list", "is_deleted", "status", "created_at"), # 내 콘텐츠 Index("idx_ssul_content_user_created", "user_uuid", "created_at"), # 시나리오 필터 Index("idx_ssul_content_scen_created", "scenario", "created_at"), # 고아 스윕 (기동 시 queued/running 조회) Index("idx_ssul_content_status", "status"), _MYSQL_OPTS, ) id: Mapped[int] = mapped_column( BigInteger, primary_key=True, nullable=False, autoincrement=True, comment="고유 식별자 (크레딧 원장 job_ref 앵커)", ) # castad `project.user_uuid` 와 동일한 정책(SET NULL). # 탈퇴해도 콘텐츠는 남고 소유자만 비워진다. user_uuid: Mapped[Optional[str]] = mapped_column( String(36), ForeignKey("user.user_uuid", ondelete="SET NULL"), nullable=True, comment="생성 요청한 사용자 UUID (탈퇴 시 NULL)", ) # ========================================================================== # 생성 요청 정보 # ========================================================================== scenario: Mapped[str] = mapped_column( String(20), nullable=False, comment="시나리오 코드 (joseon/samgukji/greek/odyssey)", ) # castad `marketing.official_site_url` 과 같은 의미다 — 영상 종료 직전 오버레이의 링크. # 생성 요청 시점에는 알 수 없어 NULL 로 시작하고, 워커가 place 페이지를 크롤링할 때 # 채운다(홈페이지 항목 우선, 없으면 네이버 플레이스 URL). 업장명만 입력해 place URL # 해석까지 실패하면 끝까지 NULL 이고, 그때는 프론트가 오버레이를 그리지 않는다. official_site_url: Mapped[Optional[str]] = mapped_column( String(2048), nullable=True, comment="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 네이버 플레이스 URL; 미확보 시 NULL)", ) # scenes / seconds 는 DB 기본값을 두지 않는다. 기본값(9 / 30)과 허용 범위 # (4~20 / 20~90)는 Pydantic 요청 스키마에서 Field(default, ge, le)로 강제한다. scenes: Mapped[int] = mapped_column( Integer, nullable=False, comment="생성할 장면 수 (요청 스키마에서 4~20 제한, 기본 9)", ) seconds: Mapped[int] = mapped_column( Integer, nullable=False, comment="장면당 초 길이 (요청 스키마에서 20~90 제한, 기본 30)", ) # ========================================================================== # 생성 잡 상태 # ========================================================================== status: Mapped[str] = mapped_column( String(20), nullable=False, default="queued", server_default="queued", comment="상태 (queued/running/done/error). 목록에는 done 만 노출", ) step: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", comment="진행 단계 0~4 (폴링 응답용. 0=준비, 4=영상 합성 완료)", ) error: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="실패 사유", ) # ========================================================================== # 산출물 (완료 시 채워짐) # ========================================================================== video_url: Mapped[Optional[str]] = mapped_column( String(500), nullable=True, comment="완성 영상 URL (Azure Blob 공개 URL 또는 로컬 서빙 경로)", ) poster_url: Mapped[Optional[str]] = mapped_column( String(2048), nullable=True, comment="포스터 URL (SNS 공유 og:image. 없으면 프론트가 시나리오 표지로 대체)", ) title: Mapped[Optional[str]] = mapped_column( String(100), nullable=True, comment="SNS 업로드 제목", ) description: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="SNS 업로드 설명", ) hashtags: Mapped[Optional[list]] = mapped_column( JSON, nullable=True, comment="SNS 해시태그 목록", ) # ========================================================================== # 목록 표시 (크롤링 후 채워짐) — castad video 는 Project 에서 가져오는 값들 # ========================================================================== # castad `project.store_name` 과 동일하게 varchar(255) NOT NULL. # 통합 목록이 이 컬럼을 UNION 하므로 타입·널 허용이 어긋나면 정렬·비교에서 # 미묘한 차이가 생긴다. # # 단 하나 다른 점: **server_default 가 빈 문자열**이다. castad `project` 는 # 크롤링이 끝난 뒤 생성되어 업장명을 이미 알지만, 썰박스는 요청 즉시 행을 만들고 # (크레딧 선차감 때문) 업장명은 그 뒤 크롤링으로 채운다. 기본값이 없으면 # 생성 자체가 불가능하다. 빈 문자열은 "아직 모름"을 뜻하며, 채우는 쪽은 # falsy 검사로 판단한다(`set_place_info`). store_name: Mapped[str] = mapped_column( String(255), nullable=False, default="", server_default="", comment="대상 업장명 (통합 목록에서 castad video.store_name 자리에 대응)", ) region: Mapped[Optional[str]] = mapped_column( String(100), nullable=True, comment="지역 (통합 목록의 지역 필터에 사용)", ) # castad `project.detail_region_info` 와 동일한 역할·타입(TEXT NULL). # 지역 필터가 `region` 만 보지 않고 **상세 주소의 별칭까지 부분 일치**로 훑기 # 때문에(`/video/all` 의 SIDO_SEARCH_ALIASES), 이 값이 없으면 썰박스는 # `region IN (cities)` 경로로만 걸려 castad 와 필터 결과가 비대칭이 된다. detail_region_info: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용", ) # views / like_count / comment_count 는 두지 않는다. # 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다 # (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다). # 카운터를 들면 쓰기 경로마다 갱신해야 하고 드리프트가 생긴다. # SNS 제목·설명·태그는 video 와 같이 이 테이블에 저장한다. is_deleted: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default="0", comment="소프트 삭제 여부", ) # updated_at 은 **보류**다. 일반론으로는 이런 가변 테이블(queued→running→step→done)에 # 두는 것이 맞고, subprocess 가 멈출 수 있어 "오래 안 움직인 잡 찾기"에도 유용하다. # 다만 castad `video`/`comment` 에 없어 썰박스만 갖는 게 비대칭이라 미뤘다. # → ADO2 쪽에 추가할 때 여기도 함께 넣는다(nullable DDL 이라 무중단 가능). created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), comment="생성 요청 일시 (목록 정렬 기준)", ) def __repr__(self) -> str: return ( f"" ) # 좋아요·댓글 모델은 여기 없다. # castad `video_reaction` / `comment` 에 합쳤다(2026-07-30) — 그쪽 행은 # ADO2 면 video_id, 썰박스면 content_id 가 채워지고 CHECK 로 하나만 강제한다. # 합친 이유: 네 테이블이 모두 0행이라 이관 비용이 없었고, `like_cache` 가 이미 # 종류별 키를 지원해 Redis write-behind 를 그대로 공유할 수 있었다. # SNS 업로드 모델도 여기 없다. # castad `social_upload` 에 병합됐다(2026-07-30, docs/database-schema/ # migration_2026-07-30_social_upload_merge.sql) — 그쪽 행은 ADO2 면 video_id, 썰박스면 # content_id 가 채워지고 CHECK 로 하나만 강제한다. 덕분에 dashboard 통계가 # 썰박스 업로드를 무수정으로 집계한다(SocialUpload 만 읽고 video_id 는 안 본다).