420 lines
16 KiB
Python
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} 변경 없음 (이미 최신)")
|