refactor(ssulbox): 자동 마이그레이션 제거, DDL 을 수동 문서로 이관
This commit is contained in:
parent
666c3bf6d1
commit
ed1ba71bfe
@ -31,20 +31,12 @@ async def lifespan(app: FastAPI):
|
|||||||
from app.dashboard.migration import init_dashboard_table
|
from app.dashboard.migration import init_dashboard_table
|
||||||
await init_dashboard_table()
|
await init_dashboard_table()
|
||||||
|
|
||||||
# 썰박스 스키마 보장 (모든 환경 - Alembic 부재 + create_db_tables 는 DEBUG 전용)
|
# 썰박스 스키마는 앱이 만들지 않는다 — **DB 변경은 전부 수동**이 방침이다.
|
||||||
# 실패해도 앱 기동은 막지 않는다. 썰박스 스키마 문제로 castad 전체가 죽으면 안 된다.
|
# 배포 전에 docs/manual_ddl/2026-07-30-ssulbox.sql 을 직접 실행할 것.
|
||||||
|
# (자동 마이그레이션 ensure_ssulbox_schema() 는 2026-07-30 제거)
|
||||||
if ssulbox_settings.SSULBOX_ENABLED:
|
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 을 환불·정리.
|
# 고아 잡 스윕 — 이전 프로세스가 죽으며 남긴 queued/running 을 환불·정리.
|
||||||
|
# 이건 DDL 이 아니라 데이터 정리(환불 UPDATE)라 앱 책임으로 남긴다.
|
||||||
# 기동 직후 인메모리 잡은 0개이므로 비터미널 잡은 전부 고아다.
|
# 기동 직후 인메모리 잡은 0개이므로 비터미널 잡은 전부 고아다.
|
||||||
# ⚠️ 이 불변식은 단일 워커 전제다(--workers 를 늘리면 정상 잡을 오판한다).
|
# ⚠️ 이 불변식은 단일 워커 전제다(--workers 를 늘리면 정상 잡을 오판한다).
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -88,7 +88,6 @@ async def create_db_tables():
|
|||||||
from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401
|
from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401
|
||||||
from app.ssulbox.models import ( # noqa: F401
|
from app.ssulbox.models import ( # noqa: F401
|
||||||
SsulContent,
|
SsulContent,
|
||||||
SsulSocialUpload,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 생성할 테이블 목록 (FK 순서: 참조 대상 먼저)
|
# 생성할 테이블 목록 (FK 순서: 참조 대상 먼저)
|
||||||
@ -112,7 +111,6 @@ async def create_db_tables():
|
|||||||
CreditTransaction.__table__,
|
CreditTransaction.__table__,
|
||||||
# 썰박스 (FK 순서: ssul_content 를 나머지가 참조)
|
# 썰박스 (FK 순서: ssul_content 를 나머지가 참조)
|
||||||
SsulContent.__table__,
|
SsulContent.__table__,
|
||||||
SsulSocialUpload.__table__,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Creating database tables...")
|
logger.info("Creating database tables...")
|
||||||
|
|||||||
@ -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} 변경 없음 (이미 최신)")
|
|
||||||
@ -12,8 +12,9 @@ mysql_engine/charset/collate 명시, 컬럼마다 comment.
|
|||||||
`updated_at` 은 대응 castad 테이블이 가진 경우에만 둔다 — `video`/`comment`/`project`/
|
`updated_at` 은 대응 castad 테이블이 가진 경우에만 둔다 — `video`/`comment`/`project`/
|
||||||
`lyric`/`song` 은 상태 전이를 겪으면서도 `created_at` 만 갖고, `social_upload` 만 예외다.
|
`lyric`/`song` 은 상태 전이를 겪으면서도 `created_at` 만 갖고, `social_upload` 만 예외다.
|
||||||
|
|
||||||
주의: Alembic 이 없다. 이 파일을 고친 뒤에는 app/ssulbox/migration.py 의
|
주의: Alembic 이 없고 **DB 변경은 전부 수동**이 방침이다. 이 파일을 고쳐도 앱은
|
||||||
ensure_ssulbox_schema() 에 대응 DDL 을 함께 추가해야 운영 DB 에 반영된다.
|
어떤 DDL 도 실행하지 않는다 — 대응 SQL 을 docs/manual_ddl/ 에 추가하고 배포 전에
|
||||||
|
직접 실행해야 운영 DB 에 반영된다. (자동 마이그레이션은 2026-07-30 제거)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -26,17 +27,16 @@ from sqlalchemy import (
|
|||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
JSON,
|
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.user.models import SocialAccount
|
pass
|
||||||
|
|
||||||
# MySQL 전용 테이블 옵션 (castad 공통)
|
# MySQL 전용 테이블 옵션 (castad 공통)
|
||||||
_MYSQL_OPTS = {
|
_MYSQL_OPTS = {
|
||||||
@ -207,7 +207,7 @@ class SsulContent(Base):
|
|||||||
|
|
||||||
# title / caption / views / like_count / comment_count 는 두지 않는다.
|
# title / caption / views / like_count / comment_count 는 두지 않는다.
|
||||||
# - castad `video` 도 제목을 갖지 않고 목록 표시는 store_name 으로 한다.
|
# - castad `video` 도 제목을 갖지 않고 목록 표시는 store_name 으로 한다.
|
||||||
# SNS 업로드 제목·설명은 업로드 시점에 작성해 ssul_social_upload 에 담고,
|
# SNS 업로드 제목·설명은 업로드 시점에 작성해 social_upload 에 담고,
|
||||||
# 다운로드 파일명은 프론트가 정한다.
|
# 다운로드 파일명은 프론트가 정한다.
|
||||||
# - 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다
|
# - 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다
|
||||||
# (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다).
|
# (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다).
|
||||||
@ -246,200 +246,8 @@ class SsulContent(Base):
|
|||||||
# 종류별 키를 지원해 Redis write-behind 를 그대로 공유할 수 있었다.
|
# 종류별 키를 지원해 Redis write-behind 를 그대로 공유할 수 있었다.
|
||||||
|
|
||||||
|
|
||||||
class SsulSocialUpload(Base):
|
# SNS 업로드 모델도 여기 없다.
|
||||||
"""썰박스 콘텐츠의 SNS 업로드 기록.
|
# castad `social_upload` 에 병합됐다(2026-07-30, docs/manual_ddl/
|
||||||
|
# 2026-07-30-social-upload-merge.sql) — 그쪽 행은 ADO2 면 video_id, 썰박스면
|
||||||
castad `social_upload` 를 재사용하지 못하는 이유: 그쪽 `video_id` 가 NOT NULL 이고
|
# content_id 가 채워지고 CHECK 로 하나만 강제한다. 덕분에 dashboard 통계가
|
||||||
`Video` 를 lazy="selectin" 으로 물고 있어, nullable 로 바꾸면
|
# 썰박스 업로드를 무수정으로 집계한다(SocialUpload 만 읽고 video_id 는 안 본다).
|
||||||
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}')>"
|
|
||||||
)
|
|
||||||
|
|||||||
@ -143,11 +143,9 @@ class User(Base):
|
|||||||
comment="카카오 썸네일 이미지 URL",
|
comment="카카오 썸네일 이미지 URL",
|
||||||
)
|
)
|
||||||
|
|
||||||
bio: Mapped[Optional[str]] = mapped_column(
|
# `bio`(썰박스 프로필 한 줄 소개)는 두지 않는다 — 원본 썰박스에는 있었지만
|
||||||
String(200),
|
# 프로필 편집 화면을 castad `내 정보`로 대체하면서 쓰는 곳이 사라졌다
|
||||||
nullable=True,
|
# (2026-07-30 제거). 프로필 소개 기능을 만들게 되면 그때 추가한다.
|
||||||
comment="한 줄 소개 (썰박스 프로필). Alembic 부재로 수동 DDL 필요 — app/ssulbox/migration.py 참조",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# 추가 사용자 정보
|
# 추가 사용자 정보
|
||||||
|
|||||||
59
docs/manual_ddl/2026-07-30-social-upload-merge.sql
Normal file
59
docs/manual_ddl/2026-07-30-social-upload-merge.sql
Normal file
@ -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 기대
|
||||||
116
docs/manual_ddl/2026-07-30-ssulbox.sql
Normal file
116
docs/manual_ddl/2026-07-30-ssulbox.sql
Normal file
@ -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';
|
||||||
Loading…
Reference in New Issue
Block a user