"""썰박스 스키마 보장. **이 프로젝트에는 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} 변경 없음 (이미 최신)")