From ed1ba71bfed025aaeecc9569baafb323d1c5a900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 11 Aug 2026 11:03:03 +0900 Subject: [PATCH] =?UTF-8?q?refactor(ssulbox):=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20DDL=20=EC=9D=84=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/common.py | 16 +- app/database/session.py | 2 - app/ssulbox/migration.py | 419 ------------------ app/ssulbox/models.py | 214 +-------- app/user/models.py | 8 +- .../2026-07-30-social-upload-merge.sql | 59 +++ docs/manual_ddl/2026-07-30-ssulbox.sql | 116 +++++ 7 files changed, 193 insertions(+), 641 deletions(-) delete mode 100644 app/ssulbox/migration.py create mode 100644 docs/manual_ddl/2026-07-30-social-upload-merge.sql create mode 100644 docs/manual_ddl/2026-07-30-ssulbox.sql diff --git a/app/core/common.py b/app/core/common.py index 6b284d1..e36a3fb 100644 --- a/app/core/common.py +++ b/app/core/common.py @@ -31,20 +31,12 @@ async def lifespan(app: FastAPI): from app.dashboard.migration import init_dashboard_table await init_dashboard_table() - # 썰박스 스키마 보장 (모든 환경 - Alembic 부재 + create_db_tables 는 DEBUG 전용) - # 실패해도 앱 기동은 막지 않는다. 썰박스 스키마 문제로 castad 전체가 죽으면 안 된다. + # 썰박스 스키마는 앱이 만들지 않는다 — **DB 변경은 전부 수동**이 방침이다. + # 배포 전에 docs/manual_ddl/2026-07-30-ssulbox.sql 을 직접 실행할 것. + # (자동 마이그레이션 ensure_ssulbox_schema() 는 2026-07-30 제거) if ssulbox_settings.SSULBOX_ENABLED: - try: - from app.ssulbox.migration import ensure_ssulbox_schema - - await ensure_ssulbox_schema() - except Exception as e: - logger.error( - f"[ssulbox] 스키마 보장 실패 (다음 기동 시 재시도): " - f"{type(e).__name__}: {e}" - ) - # 고아 잡 스윕 — 이전 프로세스가 죽으며 남긴 queued/running 을 환불·정리. + # 이건 DDL 이 아니라 데이터 정리(환불 UPDATE)라 앱 책임으로 남긴다. # 기동 직후 인메모리 잡은 0개이므로 비터미널 잡은 전부 고아다. # ⚠️ 이 불변식은 단일 워커 전제다(--workers 를 늘리면 정상 잡을 오판한다). try: diff --git a/app/database/session.py b/app/database/session.py index 58dca67..ebe7590 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -88,7 +88,6 @@ async def create_db_tables(): from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401 from app.ssulbox.models import ( # noqa: F401 SsulContent, - SsulSocialUpload, ) # 생성할 테이블 목록 (FK 순서: 참조 대상 먼저) @@ -112,7 +111,6 @@ async def create_db_tables(): CreditTransaction.__table__, # 썰박스 (FK 순서: ssul_content 를 나머지가 참조) SsulContent.__table__, - SsulSocialUpload.__table__, ] logger.info("Creating database tables...") diff --git a/app/ssulbox/migration.py b/app/ssulbox/migration.py deleted file mode 100644 index 434c226..0000000 --- a/app/ssulbox/migration.py +++ /dev/null @@ -1,419 +0,0 @@ -"""썰박스 스키마 보장. - -**이 프로젝트에는 Alembic 이 없다.** 게다가 `create_db_tables()` 는 -`prj_settings.DEBUG` 일 때만 호출되므로(app/core/common.py), 운영 환경에서는 -신규 테이블이 조용히 만들어지지 않는다. 그래서 이 모듈이 DEBUG 여부와 무관하게 -스키마를 책임진다. - -두 가지 일을 한다: - -1. **기존 테이블 ALTER** — `user.bio`, `credit_transaction.job_type/job_ref` + - `(job_type, job_ref, type)` 유니크. `create_all` 은 기존 테이블의 컬럼 변경을 - 하지 못하므로 information_schema 로 존재를 확인한 뒤 직접 DDL 을 친다. -2. **ssul_* 테이블 생성** — 신규 테이블이라 `create_all(checkfirst=True)` 로 안전하다. - -모든 단계가 멱등이다. 두 번 연속 기동해도 두 번째에는 아무 DDL 도 실행되지 않는다. - -app/dashboard/migration.py 의 information_schema 확인 패턴을 그대로 따른다. -""" - -import logging - -from sqlalchemy import text - -from app.database.session import Base, engine - -logger = logging.getLogger(__name__) - -LOG_PREFIX = "[SSULBOX_MIGRATE]" - - -# ============================================================================= -# information_schema 조회 헬퍼 -# ============================================================================= -async def _table_exists(conn, table: str) -> bool: - result = await conn.execute( - text( - "SELECT COUNT(*) FROM information_schema.tables " - "WHERE table_schema = DATABASE() AND table_name = :t" - ), - {"t": table}, - ) - return (result.scalar() or 0) > 0 - - -async def _column_exists(conn, table: str, column: str) -> bool: - result = await conn.execute( - text( - "SELECT COUNT(*) FROM information_schema.columns " - "WHERE table_schema = DATABASE() " - "AND table_name = :t AND column_name = :c" - ), - {"t": table, "c": column}, - ) - return (result.scalar() or 0) > 0 - - -async def _column_def(conn, table: str, column: str) -> tuple[str, str] | None: - """(column_type, is_nullable) 또는 없으면 None.""" - result = await conn.execute( - text( - "SELECT column_type, is_nullable FROM information_schema.columns " - "WHERE table_schema = DATABASE() " - "AND table_name = :t AND column_name = :c" - ), - {"t": table, "c": column}, - ) - row = result.first() - return (row[0], row[1]) if row else None - - -async def _check_exists(conn, table: str, name: str) -> bool: - """CHECK 제약 존재 여부. MySQL 8.0.16+ 의 information_schema 를 본다.""" - result = await conn.execute( - text( - "SELECT COUNT(*) FROM information_schema.table_constraints " - "WHERE table_schema = DATABASE() AND table_name = :t " - "AND constraint_name = :c AND constraint_type = 'CHECK'" - ), - {"t": table, "c": name}, - ) - return (result.scalar() or 0) > 0 - - -async def _index_exists(conn, table: str, index: str) -> bool: - result = await conn.execute( - text( - "SELECT COUNT(*) FROM information_schema.statistics " - "WHERE table_schema = DATABASE() " - "AND table_name = :t AND index_name = :i" - ), - {"t": table, "i": index}, - ) - return (result.scalar() or 0) > 0 - - -# ============================================================================= -# 1) 기존 테이블 ALTER -# ============================================================================= -async def _ensure_user_bio(conn) -> bool: - """user.bio 컬럼 보장 (썰박스 프로필 한 줄 소개)""" - if not await _table_exists(conn, "user"): - logger.warning(f"{LOG_PREFIX} user 테이블 없음 - bio 추가 건너뜀") - return False - if await _column_exists(conn, "user", "bio"): - return False - - await conn.execute( - text( - "ALTER TABLE `user` " - "ADD COLUMN `bio` VARCHAR(200) NULL COMMENT '한 줄 소개 (썰박스 프로필)'" - ) - ) - logger.info(f"{LOG_PREFIX} user.bio 컬럼 추가") - return True - - -async def _ensure_credit_job_keys(conn) -> bool: - """credit_transaction 에 크레딧 멱등 키 컬럼 + 유니크 제약 보장. - - (job_type, job_ref, type) 유니크가 "작업 1건당 consume 1행 / refund 1행"을 - DB 레벨에서 보장한다. MySQL 은 NULL 을 서로 다르게 취급하므로 기존 - charge/admin_adjust 행(job_type NULL)은 이 제약의 영향을 받지 않는다. - - job_ref 가 문자열인 이유: 썰박스는 ssul_content.id(숫자), castad 영상은 - video.task_id(UUID7 문자열)를 앵커로 쓰기 때문에 하나로 담으려면 문자열이어야 한다. - """ - table = "credit_transaction" - if not await _table_exists(conn, table): - logger.warning(f"{LOG_PREFIX} {table} 테이블 없음 - 멱등 키 추가 건너뜀") - return False - - changed = False - - if not await _column_exists(conn, table, "job_type"): - await conn.execute( - text( - f"ALTER TABLE `{table}` " - "ADD COLUMN `job_type` VARCHAR(20) NULL " - "COMMENT '차감 유발 작업 종류 (ssul/video)'" - ) - ) - logger.info(f"{LOG_PREFIX} {table}.job_type 컬럼 추가") - changed = True - - if not await _column_exists(conn, table, "job_ref"): - await conn.execute( - text( - f"ALTER TABLE `{table}` " - "ADD COLUMN `job_ref` VARCHAR(64) NULL " - "COMMENT '작업 식별자 (ssul_content.id 문자열 또는 video.task_id)'" - ) - ) - logger.info(f"{LOG_PREFIX} {table}.job_ref 컬럼 추가") - changed = True - - if not await _index_exists(conn, table, "uq_credit_job"): - await conn.execute( - text( - f"ALTER TABLE `{table}` " - "ADD UNIQUE KEY `uq_credit_job` (`job_type`, `job_ref`, `type`)" - ) - ) - logger.info(f"{LOG_PREFIX} {table}.uq_credit_job 유니크 제약 추가") - changed = True - - return changed - - -# ============================================================================= -# 2) ssul_* 테이블 생성 -# ============================================================================= -def _ssul_tables() -> list: - """생성할 ssul_* 테이블 목록 (FK 순서: 참조 대상 먼저) - - ssul_* 는 `user.user_uuid` 와 `social_account.id` 를 참조한다. create_all 은 FK 를 - 해석할 때 **참조 대상 테이블이 Base.metadata 에 등록되어 있어야** 하므로, - 생성 목록에 넣지 않더라도 app.user.models 를 함께 import 해야 한다. - (없으면 NoReferencedTableError 로 실패한다.) - """ - from app.user.models import SocialAccount, User # noqa: F401 # 메타데이터 등록용 - from app.ssulbox.models import SsulContent, SsulSocialUpload - - # 좋아요·댓글은 castad `video_reaction` / `comment` 에 합쳤으므로 - # ssul_like / ssul_comment 는 만들지 않는다(_drop_legacy_... 가 정리한다). - # ssul_content 를 나머지가 참조하므로 먼저 만든다. - return [ - SsulContent.__table__, - SsulSocialUpload.__table__, - ] - - -# ============================================================================= -# 진입점 -# ============================================================================= -async def _ensure_ssul_detail_region(conn) -> bool: - """ssul_content 에 detail_region_info 컬럼 보장. - - `create_all` 은 **없는 테이블만** 만들고 기존 테이블에 컬럼을 붙이지 않으므로, - 이미 만들어진 환경을 위해 별도 ALTER 가 필요하다. - - castad `project.detail_region_info` 와 같은 TEXT NULL 이다. 통합 목록의 지역 - 필터가 `region` 만 보지 않고 상세 주소를 별칭으로 부분 일치 검색하기 때문에, - 이 컬럼이 없으면 썰박스 콘텐츠만 필터 결과가 달라진다. - """ - table = "ssul_content" - # 테이블이 아직 없으면 create_all 이 컬럼까지 포함해 만든다 → 여기서 할 일 없음 - if not await _table_exists(conn, table): - return False - if await _column_exists(conn, table, "detail_region_info"): - return False - - await conn.execute( - text( - f"ALTER TABLE `{table}` " - "ADD COLUMN `detail_region_info` TEXT NULL " - "COMMENT '상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용'" - ) - ) - logger.info(f"{LOG_PREFIX} {table}.detail_region_info 컬럼 추가") - return True - - -async def _ensure_ssul_store_name(conn) -> bool: - """ssul_content.store_name 을 castad `project.store_name` 과 같은 정의로 맞춘다. - - 목표: `VARCHAR(255) NOT NULL DEFAULT ''` - (초기 이식본은 `VARCHAR(200) NULL` 이었다) - - 통합 목록이 이 컬럼을 UNION 하므로 폭·널 허용이 어긋나면 정렬·비교가 미묘하게 - 달라진다. NOT NULL 로 바꾸면 UNION 결과에 NULL 이 섞이지 않아 응답 매핑에서 - COALESCE 도 필요 없다. - - **NULL 행을 먼저 빈 문자열로 바꾼다.** 그러지 않으면 MySQL 이 strict 모드에서 - MODIFY 를 거부하고, 비-strict 모드에서는 경고만 내고 조용히 변환한다 — - 어느 쪽이든 명시적으로 처리하는 편이 안전하다. - """ - table = "ssul_content" - if not await _table_exists(conn, table): - return False # create_all 이 올바른 정의로 만든다 - - current = await _column_def(conn, table, "store_name") - if current is None: - return False - if current == ("varchar(255)", "NO"): - return False # 이미 목표 정의 - - null_count = ( - await conn.execute( - text(f"SELECT COUNT(*) FROM `{table}` WHERE `store_name` IS NULL") - ) - ).scalar() or 0 - if null_count: - await conn.execute( - text(f"UPDATE `{table}` SET `store_name` = '' WHERE `store_name` IS NULL") - ) - logger.info( - f"{LOG_PREFIX} {table}.store_name NULL {null_count}건 → 빈 문자열" - ) - - await conn.execute( - text( - f"ALTER TABLE `{table}` " - "MODIFY COLUMN `store_name` VARCHAR(255) NOT NULL DEFAULT '' " - "COMMENT '대상 업장명 (통합 목록에서 castad video.store_name 자리에 대응)'" - ) - ) - logger.info( - f"{LOG_PREFIX} {table}.store_name {current} → ('varchar(255)', 'NO')" - ) - return True - - -async def _ensure_reaction_tables_merged(conn) -> bool: - """`comment` / `video_reaction` 이 썰박스 콘텐츠도 담도록 확장한다. - - 원래는 `ssul_comment` / `ssul_like` 를 따로 뒀으나, `social_upload` 병합 결정과 - 맞춰 하나로 합쳤다(2026-07-30). 네 테이블이 모두 0행이던 시점에 수행했다. - - 각 테이블에 대해: - - `video_id` NOT NULL → NULL (썰박스 행은 비운다) - - `content_id BIGINT NULL` + FK ssul_content 추가 - - "정확히 하나만 채워짐" CHECK 추가 - - content_id 조회용 인덱스/유니크 추가 - - **CHECK 가 핵심이다.** 이게 없으면 둘 다 NULL 이거나 둘 다 채워진 행이 조용히 - 생긴다. MySQL 8.0.16+ 에서 실제로 강제된다(현재 8.4). - """ - changed = False - - specs = ( - # (테이블, CHECK 이름, 추가 인덱스 SQL 목록) - ( - "comment", - "ck_comment_one_target", - ["ADD INDEX `idx_comment_content_id` (`content_id`)"], - ), - ( - "video_reaction", - "ck_video_reaction_one_target", - [ - "ADD INDEX `idx_video_reaction_content_id` (`content_id`)", - "ADD UNIQUE KEY `uq_video_reaction_user_content` " - "(`user_uuid`, `content_id`)", - ], - ), - ) - - for table, check_name, extra in specs: - if not await _table_exists(conn, table): - continue - - if not await _column_exists(conn, table, "content_id"): - await conn.execute( - text( - f"ALTER TABLE `{table}` " - "ADD COLUMN `content_id` BIGINT NULL " - "COMMENT '썰박스 콘텐츠 id (ADO2 대상이면 NULL)', " - f"ADD CONSTRAINT `fk_{table}_content` " - "FOREIGN KEY (`content_id`) REFERENCES `ssul_content` (`id`) " - "ON DELETE CASCADE" - ) - ) - for sql in extra: - await conn.execute(text(f"ALTER TABLE `{table}` {sql}")) - logger.info(f"{LOG_PREFIX} {table}.content_id 추가 (+FK/인덱스)") - changed = True - - # video_id 를 nullable 로. 기존 행은 전부 ADO2 라 값이 있어 영향이 없다. - col = await _column_def(conn, table, "video_id") - if col and col[1] == "NO": - await conn.execute( - text( - f"ALTER TABLE `{table}` " - "MODIFY COLUMN `video_id` INT NULL " - "COMMENT 'ADO2 영상 id (썰박스 대상이면 NULL)'" - ) - ) - logger.info(f"{LOG_PREFIX} {table}.video_id → NULL 허용") - changed = True - - if not await _check_exists(conn, table, check_name): - await conn.execute( - text( - f"ALTER TABLE `{table}` ADD CONSTRAINT `{check_name}` " - "CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL))" - ) - ) - logger.info(f"{LOG_PREFIX} {table}.{check_name} CHECK 추가") - changed = True - - return changed - - -async def _drop_legacy_ssul_reaction_tables(conn) -> bool: - """병합으로 쓰임이 없어진 `ssul_like` / `ssul_comment` 제거. - - **비어 있을 때만 지운다.** 행이 남아 있으면 이관이 끝나지 않은 것이므로 - 조용히 데이터를 버리지 않고 경고만 남긴다. - """ - changed = False - for table in ("ssul_like", "ssul_comment"): - if not await _table_exists(conn, table): - continue - n = (await conn.execute(text(f"SELECT COUNT(*) FROM `{table}`"))).scalar() or 0 - if n: - logger.warning( - f"{LOG_PREFIX} {table} 에 {n}행이 남아 있어 삭제하지 않는다 " - "(comment/video_reaction 으로 이관 후 수동 삭제할 것)" - ) - continue - await conn.execute(text(f"DROP TABLE `{table}`")) - logger.info(f"{LOG_PREFIX} {table} 삭제 (병합 완료, 0행)") - changed = True - return changed - - -async def ensure_ssulbox_schema() -> None: - """썰박스 스키마 보장. lifespan startup 에서 호출한다. - - 멱등이므로 매 기동마다 호출해도 안전하다. 실패는 호출부에서 삼켜야 한다 — - 썰박스 스키마 문제로 castad 전체가 기동하지 못하면 안 된다. - """ - logger.info(f"{LOG_PREFIX} 스키마 확인 시작") - - # 기존 테이블 ALTER (DDL 은 MySQL 에서 암묵적 커밋이라 개별 실행) - async with engine.begin() as conn: - altered_bio = await _ensure_user_bio(conn) - altered_credit = await _ensure_credit_job_keys(conn) - # 테이블이 아직 없으면 아래 create_all 이 올바른 정의로 만든다. - altered_region = await _ensure_ssul_detail_region(conn) - altered_store = await _ensure_ssul_store_name(conn) - - # 신규 테이블 생성 — DEBUG 여부와 무관하게 항상 보장한다. - # 신규 테이블이므로 create_all 이 기존 데이터를 위협하지 않는다. - tables = _ssul_tables() - async with engine.begin() as conn: - await conn.run_sync( - lambda sync_conn: Base.metadata.create_all( - sync_conn, tables=tables, checkfirst=True - ) - ) - - # 반응 테이블 병합은 **ssul_content 가 존재한 뒤**에 해야 한다 — - # comment/video_reaction 이 그쪽으로 FK 를 건다. - async with engine.begin() as conn: - altered_merge = await _ensure_reaction_tables_merged(conn) - dropped_legacy = await _drop_legacy_ssul_reaction_tables(conn) - - if ( - altered_bio - or altered_credit - or altered_region - or altered_store - or altered_merge - or dropped_legacy - ): - logger.info(f"{LOG_PREFIX} 스키마 변경 적용 완료") - else: - logger.info(f"{LOG_PREFIX} 변경 없음 (이미 최신)") diff --git a/app/ssulbox/models.py b/app/ssulbox/models.py index ee352bf..5c713bd 100644 --- a/app/ssulbox/models.py +++ b/app/ssulbox/models.py @@ -12,8 +12,9 @@ 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 에 반영된다. +주의: Alembic 이 없고 **DB 변경은 전부 수동**이 방침이다. 이 파일을 고쳐도 앱은 +어떤 DDL 도 실행하지 않는다 — 대응 SQL 을 docs/manual_ddl/ 에 추가하고 배포 전에 +직접 실행해야 운영 DB 에 반영된다. (자동 마이그레이션은 2026-07-30 제거) """ from datetime import datetime @@ -26,17 +27,16 @@ from sqlalchemy import ( ForeignKey, Index, Integer, - JSON, String, Text, func, ) -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import Mapped, mapped_column from app.database.session import Base if TYPE_CHECKING: - from app.user.models import SocialAccount + pass # MySQL 전용 테이블 옵션 (castad 공통) _MYSQL_OPTS = { @@ -207,7 +207,7 @@ class SsulContent(Base): # title / caption / views / like_count / comment_count 는 두지 않는다. # - castad `video` 도 제목을 갖지 않고 목록 표시는 store_name 으로 한다. - # SNS 업로드 제목·설명은 업로드 시점에 작성해 ssul_social_upload 에 담고, + # SNS 업로드 제목·설명은 업로드 시점에 작성해 social_upload 에 담고, # 다운로드 파일명은 프론트가 정한다. # - 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다 # (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다). @@ -246,200 +246,8 @@ class SsulContent(Base): # 종류별 키를 지원해 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"" - ) +# SNS 업로드 모델도 여기 없다. +# castad `social_upload` 에 병합됐다(2026-07-30, docs/manual_ddl/ +# 2026-07-30-social-upload-merge.sql) — 그쪽 행은 ADO2 면 video_id, 썰박스면 +# content_id 가 채워지고 CHECK 로 하나만 강제한다. 덕분에 dashboard 통계가 +# 썰박스 업로드를 무수정으로 집계한다(SocialUpload 만 읽고 video_id 는 안 본다). diff --git a/app/user/models.py b/app/user/models.py index 392d18a..705c879 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -143,11 +143,9 @@ class User(Base): comment="카카오 썸네일 이미지 URL", ) - bio: Mapped[Optional[str]] = mapped_column( - String(200), - nullable=True, - comment="한 줄 소개 (썰박스 프로필). Alembic 부재로 수동 DDL 필요 — app/ssulbox/migration.py 참조", - ) + # `bio`(썰박스 프로필 한 줄 소개)는 두지 않는다 — 원본 썰박스에는 있었지만 + # 프로필 편집 화면을 castad `내 정보`로 대체하면서 쓰는 곳이 사라졌다 + # (2026-07-30 제거). 프로필 소개 기능을 만들게 되면 그때 추가한다. # ========================================================================== # 추가 사용자 정보 diff --git a/docs/manual_ddl/2026-07-30-social-upload-merge.sql b/docs/manual_ddl/2026-07-30-social-upload-merge.sql new file mode 100644 index 0000000..2a5cbff --- /dev/null +++ b/docs/manual_ddl/2026-07-30-social-upload-merge.sql @@ -0,0 +1,59 @@ +-- ============================================================================= +-- social_upload 병합 DDL (수동 실행 전용) +-- +-- `ssul_social_upload` 를 없애고 castad `social_upload` 가 ADO2 영상과 썰박스 +-- 콘텐츠의 업로드 기록을 함께 담는다 — comment / video_reaction 병합과 같은 패턴: +-- 대상은 `video_id` 또는 `content_id` 중 **정확히 하나**만 채워지며 CHECK 로 강제. +-- +-- 병합의 실익: +-- - dashboard 통계(`app/dashboard/`)가 SocialUpload 만 읽는데 video_id 를 보지 +-- 않으므로, 병합하면 썰박스 업로드도 **자동으로 통계에 잡힌다.** +-- - 업로드 서비스·워커·모달을 테이블 분기 없이 재사용한다. +-- +-- 전제(2026-07-30 확인): ssul_social_upload 0행, social_upload 1행(video_id 채워져 +-- 있어 CHECK 를 이미 만족). 이관할 데이터가 없다. +-- +-- 실행 순서: 2026-07-30-ssulbox.sql 의 §1(ssul_content 생성) 이후. +-- 대상: MySQL 8.0.16+ (CHECK 가 실제로 강제되는 버전. 운영은 8.4) +-- ============================================================================= + + +-- 1. content_id 추가 + FK. +-- upload_seq 채번 인덱스 (content_id, social_account_id, upload_seq) 는 +-- 선행 컬럼이 content_id 라 FK 인덱스 요건도 겸한다(단독 인덱스 불필요 — +-- video_reaction 때는 유니크가 user_uuid 선행이라 단독이 필요했던 것과 다르다). +ALTER TABLE `social_upload` + ADD COLUMN `content_id` BIGINT NULL + COMMENT '썰박스 콘텐츠 id (ADO2 업로드면 NULL)' AFTER `video_id`, + ADD CONSTRAINT `fk_social_upload_content` + FOREIGN KEY (`content_id`) REFERENCES `ssul_content` (`id`) ON DELETE CASCADE, + ADD INDEX `idx_social_upload_content_seq` + (`content_id`, `social_account_id`, `upload_seq`); + +-- 2. video_id 를 nullable 로. 기존 행은 전부 ADO2 업로드라 값이 있어 영향 없다. +ALTER TABLE `social_upload` + MODIFY COLUMN `video_id` INT NULL COMMENT 'ADO2 영상 id (썰박스 업로드면 NULL)'; + +-- 3. "정확히 하나만 채워짐" 강제. 이게 없으면 둘 다 NULL 이거나 둘 다 채워진 +-- 행이 조용히 생긴다. +ALTER TABLE `social_upload` + ADD CONSTRAINT `ck_social_upload_one_target` + CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL)); + +-- 4. 과도기 테이블 제거 (로컬 등 이미 만든 환경만 해당. 운영은 애초에 안 만든다). +-- ⚠️ 행이 남아 있으면 social_upload 로 이관한 뒤에 지울 것. +-- (2026-07-30 확인 시점에는 0행이라 이관이 필요 없었다) +DROP TABLE IF EXISTS `ssul_social_upload`; + + +-- ============================================================================= +-- 검증 쿼리 (실행 후 확인용) +-- ============================================================================= +-- SELECT column_name, column_type, is_nullable FROM information_schema.columns +-- WHERE table_schema = DATABASE() AND table_name = 'social_upload' +-- AND column_name IN ('video_id','content_id'); +-- SELECT constraint_name FROM information_schema.table_constraints +-- WHERE table_schema = DATABASE() AND table_name = 'social_upload' +-- AND constraint_type = 'CHECK'; +-- SELECT COUNT(*) FROM information_schema.tables +-- WHERE table_schema = DATABASE() AND table_name = 'ssul_social_upload'; -- 0 기대 diff --git a/docs/manual_ddl/2026-07-30-ssulbox.sql b/docs/manual_ddl/2026-07-30-ssulbox.sql new file mode 100644 index 0000000..aceca3a --- /dev/null +++ b/docs/manual_ddl/2026-07-30-ssulbox.sql @@ -0,0 +1,116 @@ +-- ============================================================================= +-- 썰박스 통합 DDL (수동 실행 전용) +-- +-- 자동 마이그레이션(구 app/ssulbox/migration.py)을 제거하면서 그 내용 전체를 +-- 여기로 옮겼다. **앱은 어떤 DDL 도 실행하지 않는다** — 배포 전에 이 파일을 +-- 운영 DB 에 직접 실행할 것. +-- +-- 기준: 2026-07-30 로컬 DB 의 SHOW CREATE TABLE (모델 app/ssulbox/models.py, +-- app/comment/models.py, app/video/models.py 와 일치 확인). +-- 대상: MySQL 8.0.16+ (CHECK 제약이 실제로 강제되는 버전. 운영은 8.4) +-- +-- 실행 순서가 중요하다: +-- §1 신규 테이블 → §2 기존 테이블 확장(§1 의 ssul_content 를 FK 로 참조) +-- +-- MySQL 은 ADD COLUMN IF NOT EXISTS 를 지원하지 않는다. 일부만 적용된 DB 라면 +-- 이미 적용된 문은 개별적으로 건너뛸 것 (각 문이 독립적으로 실행 가능하다). +-- ============================================================================= + + +-- ============================================================================= +-- §1. 신규 테이블 +-- ============================================================================= + +-- 썰박스 1편 — 생성 잡과 산출물을 한 행으로 관리 (castad video 와 같은 구조). +-- id 가 크레딧 원장 멱등 키(job_type='ssul', job_ref=str(id))의 앵커다. +CREATE TABLE IF NOT EXISTS `ssul_content` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '고유 식별자 (크레딧 원장 job_ref 앵커)', + `user_uuid` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '생성 요청한 사용자 UUID (탈퇴 시 NULL)', + `scenario` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '시나리오 코드 (joseon/samgukji/greek/odyssey)', + `input` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '입력값 (네이버 지도 URL 또는 업장명)', + `scenes` int NOT NULL COMMENT '생성할 장면 수 (요청 스키마에서 4~20 제한, 기본 9)', + `seconds` int NOT NULL COMMENT '장면당 초 길이 (요청 스키마에서 20~90 제한, 기본 30)', + `status` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'queued' COMMENT '상태 (queued/running/done/error). 목록에는 done 만 노출', + `step` int NOT NULL DEFAULT '0' COMMENT '진행 단계 0~4 (폴링 응답용. 0=준비, 4=영상 합성 완료)', + `error` text COLLATE utf8mb4_unicode_ci COMMENT '실패 사유', + `video_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '완성 영상 URL (Azure Blob 공개 URL 또는 로컬 서빙 경로)', + `thumbnail_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '썸네일 URL (없으면 프론트가 시나리오 표지로 대체)', + `store_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '대상 업장명 (통합 목록에서 castad video.store_name 자리에 대응)', + `region` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '지역 (통합 목록의 지역 필터에 사용)', + `detail_region_info` text COLLATE utf8mb4_unicode_ci COMMENT '상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '소프트 삭제 여부', + `created_at` datetime NOT NULL DEFAULT (now()) COMMENT '생성 요청 일시 (목록 정렬 기준)', + PRIMARY KEY (`id`), + KEY `idx_ssul_content_status` (`status`), + KEY `idx_ssul_content_list` (`is_deleted`,`status`,`created_at`), + KEY `idx_ssul_content_user_created` (`user_uuid`,`created_at`), + KEY `idx_ssul_content_scen_created` (`scenario`,`created_at`), + CONSTRAINT `ssul_content_ibfk_1` FOREIGN KEY (`user_uuid`) REFERENCES `user` (`user_uuid`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ============================================================================= +-- §2. 기존 테이블 확장 +-- ============================================================================= + +-- 2-1. credit_transaction — 크레딧 멱등 키. +-- (job_type, job_ref, type) 유니크가 "작업 1건당 consume 1행 / refund 1행"을 +-- DB 레벨에서 보장한다. 기존 행은 job_type 이 NULL 이라 제약에 걸리지 않는다 +-- (MySQL 은 유니크에서 NULL 을 서로 다른 값으로 취급). +ALTER TABLE `credit_transaction` + ADD COLUMN `job_type` VARCHAR(20) NULL COMMENT '차감 유발 작업 종류 (ssul/video)', + ADD COLUMN `job_ref` VARCHAR(64) NULL COMMENT '작업 식별자 (ssul_content.id 문자열 또는 video.task_id)', + ADD UNIQUE KEY `uq_credit_job` (`job_type`, `job_ref`, `type`); + +-- 2-2. comment — ADO2 영상과 썰박스 댓글을 한 테이블로. +-- 대상은 video_id / content_id 중 **정확히 하나**만 채워지며 CHECK 로 강제한다. +-- 기존 행은 전부 video_id 가 채워져 있어 CHECK 를 이미 만족한다. +ALTER TABLE `comment` + ADD COLUMN `content_id` BIGINT NULL COMMENT '썰박스 콘텐츠 id (ADO2 댓글이면 NULL)', + ADD CONSTRAINT `fk_comment_content` + FOREIGN KEY (`content_id`) REFERENCES `ssul_content` (`id`) ON DELETE CASCADE; + +ALTER TABLE `comment` + ADD INDEX `idx_comment_content_id` (`content_id`); + +ALTER TABLE `comment` + MODIFY COLUMN `video_id` INT NULL COMMENT 'ADO2 영상 id (썰박스 댓글이면 NULL)'; + +ALTER TABLE `comment` + ADD CONSTRAINT `ck_comment_one_target` + CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL)); + +-- 2-3. video_reaction — 좋아요도 동일하게 병합. +-- 1인 1회 보장은 유니크 두 개로 나눈다. 썰박스 행(video_id IS NULL)은 +-- uq_video_reaction_user_video 에 걸리지 않는다(NULL 은 서로 다른 값 취급). +-- content_id 단독 인덱스는 카운트 집계(GROUP BY content_id)용이다 — +-- 유니크는 user_uuid 가 선행이라 이 용도로 못 쓴다. +ALTER TABLE `video_reaction` + ADD COLUMN `content_id` BIGINT NULL COMMENT '썰박스 콘텐츠 id (ADO2 반응이면 NULL)', + ADD CONSTRAINT `fk_video_reaction_content` + FOREIGN KEY (`content_id`) REFERENCES `ssul_content` (`id`) ON DELETE CASCADE; + +ALTER TABLE `video_reaction` + ADD INDEX `idx_video_reaction_content_id` (`content_id`), + ADD UNIQUE KEY `uq_video_reaction_user_content` (`user_uuid`, `content_id`); + +ALTER TABLE `video_reaction` + MODIFY COLUMN `video_id` INT NULL COMMENT 'ADO2 영상 id (썰박스 반응이면 NULL)'; + +ALTER TABLE `video_reaction` + ADD CONSTRAINT `ck_video_reaction_one_target` + CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL)); + + +-- ============================================================================= +-- 검증 쿼리 (실행 후 확인용) +-- ============================================================================= +-- SELECT column_name, column_type, is_nullable FROM information_schema.columns +-- WHERE table_schema = DATABASE() AND table_name IN ('comment','video_reaction') +-- AND column_name IN ('video_id','content_id'); +-- SELECT constraint_name FROM information_schema.table_constraints +-- WHERE table_schema = DATABASE() AND constraint_type = 'CHECK' +-- AND table_name IN ('comment','video_reaction'); +-- SHOW INDEX FROM credit_transaction WHERE Key_name = 'uq_credit_job'; +-- SELECT COUNT(*) FROM information_schema.columns +-- WHERE table_schema = DATABASE() AND table_name = 'user' AND column_name = 'bio';