o2o-castad-backend/app/ssulbox/migration.py

420 lines
16 KiB
Python

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