446 lines
16 KiB
Python
446 lines
16 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 이 없다. 이 파일을 고친 뒤에는 app/ssulbox/migration.py 의
|
|
ensure_ssulbox_schema() 에 대응 DDL 을 함께 추가해야 운영 DB 에 반영된다.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
Boolean,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
JSON,
|
|
String,
|
|
Text,
|
|
func,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.database.session import Base
|
|
|
|
if TYPE_CHECKING:
|
|
from app.user.models import SocialAccount
|
|
|
|
# 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 또는 로컬 서빙 경로)",
|
|
)
|
|
|
|
thumbnail_url: Mapped[Optional[str]] = mapped_column(
|
|
String(500),
|
|
nullable=True,
|
|
comment="썸네일 URL (없으면 프론트가 시나리오 표지로 대체)",
|
|
)
|
|
|
|
# ==========================================================================
|
|
# 목록 표시 (크롤링 후 채워짐) — 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="상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용",
|
|
)
|
|
|
|
# title / caption / views / like_count / comment_count 는 두지 않는다.
|
|
# - castad `video` 도 제목을 갖지 않고 목록 표시는 store_name 으로 한다.
|
|
# SNS 업로드 제목·설명은 업로드 시점에 작성해 ssul_social_upload 에 담고,
|
|
# 다운로드 파일명은 프론트가 정한다.
|
|
# - 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다
|
|
# (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다).
|
|
# 카운터를 들면 쓰기 경로마다 갱신해야 하고 드리프트가 생긴다.
|
|
|
|
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 를 그대로 공유할 수 있었다.
|
|
|
|
|
|
class SsulSocialUpload(Base):
|
|
"""썰박스 콘텐츠의 SNS 업로드 기록.
|
|
|
|
castad `social_upload` 를 재사용하지 못하는 이유: 그쪽 `video_id` 가 NOT NULL 이고
|
|
`Video` 를 lazy="selectin" 으로 물고 있어, nullable 로 바꾸면
|
|
app/social/services/upload_service.py · app/dashboard/migration.py · 백오피스가
|
|
모두 영향을 받는다. 대신 구조를 그대로 본떠 신설한다 —
|
|
**컬럼 구성은 castad social_upload 와 완전히 동일하다(21개).**
|
|
차이는 두 가지뿐이다: content_id 가 bigint(ssul_content.id 를 따름), 그리고
|
|
DB 기본값(server_default)을 명시해 ORM 을 우회한 INSERT 도 안전하게 했다.
|
|
|
|
예약 업로드(`scheduled_at`)는 castad 에서 이미 동작한다
|
|
(app/social/services/upload_service.py 가 예약/즉시를 분기하고 충돌 검사도 한다).
|
|
이식 시 같은 서비스 로직을 재사용할 수 있다.
|
|
|
|
알려진 대가: app/dashboard/migration.py 가 SocialUpload 만 읽으므로 썰박스 업로드는
|
|
대시보드 통계에 잡히지 않는다. 통합 시점은 별도 결정 사항이다.
|
|
"""
|
|
|
|
__tablename__ = "ssul_social_upload"
|
|
__table_args__ = (
|
|
# (content_id, social_account_id, upload_seq) 가 앞 2개 컬럼 조회도 커버하므로
|
|
# (content_id, social_account_id) 를 따로 두지 않는다.
|
|
# 참고: castad social_upload 에는 이 중복이 남아 있다(video_account, video_id).
|
|
Index("idx_ssul_upload_seq", "content_id", "social_account_id", "upload_seq"),
|
|
Index("idx_ssul_upload_user", "user_uuid"),
|
|
Index("idx_ssul_upload_status", "status"),
|
|
Index("idx_ssul_upload_platform", "platform"),
|
|
Index("idx_ssul_upload_created_at", "created_at"),
|
|
_MYSQL_OPTS,
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(
|
|
BigInteger,
|
|
primary_key=True,
|
|
nullable=False,
|
|
autoincrement=True,
|
|
comment="고유 식별자",
|
|
)
|
|
|
|
user_uuid: Mapped[str] = mapped_column(
|
|
String(36),
|
|
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
nullable=False,
|
|
comment="업로드한 사용자 UUID",
|
|
)
|
|
|
|
content_id: Mapped[int] = mapped_column(
|
|
BigInteger,
|
|
ForeignKey("ssul_content.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
comment="업로드 대상 콘텐츠 ID",
|
|
)
|
|
|
|
social_account_id: Mapped[int] = mapped_column(
|
|
# social_account.id 는 BIGINT 가 아니라 INT 다(castad social_upload 도 동일).
|
|
# BigInteger 로 두면 MySQL 이 FK 타입 불일치(errno 3780)로 생성을 거부한다.
|
|
Integer,
|
|
ForeignKey("social_account.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
comment="연동 SNS 계정 ID (castad social_account 재사용)",
|
|
)
|
|
|
|
upload_seq: Mapped[int] = mapped_column(
|
|
Integer,
|
|
nullable=False,
|
|
default=1,
|
|
server_default="1",
|
|
comment="(content, account) 조합 내 업로드 순번 — 재업로드 버전 관리",
|
|
)
|
|
|
|
platform: Mapped[str] = mapped_column(
|
|
String(20),
|
|
nullable=False,
|
|
comment="플랫폼 (youtube/instagram/facebook/tiktok)",
|
|
)
|
|
|
|
status: Mapped[str] = mapped_column(
|
|
String(20),
|
|
nullable=False,
|
|
default="pending",
|
|
server_default="pending",
|
|
comment="상태 (scheduled/pending/uploading/processing/completed/failed)",
|
|
)
|
|
|
|
upload_progress: Mapped[int] = mapped_column(
|
|
Integer,
|
|
nullable=False,
|
|
default=0,
|
|
server_default="0",
|
|
comment="업로드 진행률 (0~100)",
|
|
)
|
|
|
|
platform_video_id: Mapped[Optional[str]] = mapped_column(
|
|
String(100),
|
|
nullable=True,
|
|
comment="플랫폼 측 영상 ID",
|
|
)
|
|
|
|
platform_url: Mapped[Optional[str]] = mapped_column(
|
|
String(500),
|
|
nullable=True,
|
|
comment="플랫폼 측 영상 URL",
|
|
)
|
|
|
|
title: Mapped[str] = mapped_column(
|
|
String(200),
|
|
nullable=False,
|
|
default="",
|
|
server_default="",
|
|
comment="업로드 제목",
|
|
)
|
|
|
|
description: Mapped[Optional[str]] = mapped_column(
|
|
Text,
|
|
nullable=True,
|
|
comment="업로드 설명",
|
|
)
|
|
|
|
tags: Mapped[Optional[list]] = mapped_column(
|
|
JSON,
|
|
nullable=True,
|
|
comment="태그 목록",
|
|
)
|
|
|
|
privacy_status: Mapped[str] = mapped_column(
|
|
String(20),
|
|
nullable=False,
|
|
default="public",
|
|
server_default="public",
|
|
comment="공개 범위 (public/unlisted/private)",
|
|
)
|
|
|
|
scheduled_at: Mapped[Optional[datetime]] = mapped_column(
|
|
DateTime,
|
|
nullable=True,
|
|
comment="예약 업로드 시각 (NULL 이면 즉시 업로드)",
|
|
)
|
|
|
|
platform_options: Mapped[Optional[dict]] = mapped_column(
|
|
JSON,
|
|
nullable=True,
|
|
comment="플랫폼별 추가 옵션",
|
|
)
|
|
|
|
error_message: Mapped[Optional[str]] = mapped_column(
|
|
Text,
|
|
nullable=True,
|
|
comment="실패 사유",
|
|
)
|
|
|
|
retry_count: Mapped[int] = mapped_column(
|
|
Integer,
|
|
nullable=False,
|
|
default=0,
|
|
server_default="0",
|
|
comment="재시도 횟수",
|
|
)
|
|
|
|
uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
|
DateTime,
|
|
nullable=True,
|
|
comment="업로드 완료 일시",
|
|
)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime,
|
|
nullable=False,
|
|
server_default=func.now(),
|
|
comment="생성 일시",
|
|
)
|
|
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime,
|
|
nullable=False,
|
|
server_default=func.now(),
|
|
onupdate=func.now(),
|
|
comment="수정 일시",
|
|
)
|
|
|
|
content: Mapped["SsulContent"] = relationship(
|
|
"SsulContent",
|
|
foreign_keys=[content_id],
|
|
lazy="noload",
|
|
)
|
|
|
|
social_account: Mapped["SocialAccount"] = relationship(
|
|
"SocialAccount",
|
|
foreign_keys=[social_account_id],
|
|
lazy="noload",
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f"<SsulSocialUpload(id={self.id}, content_id={self.content_id}, "
|
|
f"platform='{self.platform}', status='{self.status}')>"
|
|
)
|