o2o-castad-backend/app/ssulbox/models.py
김성경 e47a97476d feat(ssulbox): SNS 제목·설명·태그를 ssul_content에 저장
영상과 같이 업로드·SEO 결과를 행에 남기고, 다음 요청부터는 DB 값을 쓴다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:31:44 +09:00

271 lines
11 KiB
Python

"""썰박스 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)",
)
input: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="입력값 (네이버 지도 URL 또는 업장명)",
)
# 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"<SsulContent(id={self.id}, scenario='{self.scenario}', "
f"status='{self.status}', store_name='{self.store_name}')>"
)
# 좋아요·댓글 모델은 여기 없다.
# 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 는 안 본다).