diff --git a/.gitignore b/.gitignore index cdd7623..6bfa059 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,7 @@ Dockerfile zzz/ credentials/service_account.json -o2o-castad-scheduler/ \ No newline at end of file +o2o-castad-scheduler/ + +generator/output/ +generator/*/load_image/ \ No newline at end of file diff --git a/app/archive/api/routers/v1/archive.py b/app/archive/api/routers/v1/archive.py index 17f5817..64721d1 100644 --- a/app/archive/api/routers/v1/archive.py +++ b/app/archive/api/routers/v1/archive.py @@ -16,10 +16,9 @@ from app.user.dependencies.auth import get_current_user from app.user.models import User from app.utils.logger import get_logger from app.utils.pagination import PaginatedResponse -from app.comment.models import Comment -from app.database.like_cache import get_like_counts, mset_like_counts -from app.video.models import Video, VideoReaction +from app.video.models import Video from app.video.schemas.video_schema import VideoListItem +from app.video.services import unified_list logger = get_logger(__name__) @@ -79,89 +78,27 @@ async def get_videos( try: offset = (pagination.page - 1) * pagination.page_size - # 서브쿼리: task_id별 최신 Video ID 추출 - # id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치 - latest_video_ids = ( - select(func.max(Video.id).label("latest_id")) - .join(Project, Video.project_id == Project.id) - .where( - Project.user_uuid == current_user.user_uuid, - Video.status == "completed", - Video.is_deleted == False, # noqa: E712 - Project.is_deleted == False, # noqa: E712 - ) - .group_by(Video.task_id) - .subquery() + items, total = await unified_list.fetch_my_contents( + session, + user_uuid=current_user.user_uuid, + offset=offset, + limit=pagination.page_size, ) - - # 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만) - count_query = select(func.count(Video.id)).where( - Video.id.in_(select(latest_video_ids.c.latest_id)) - ) - total_result = await session.execute(count_query) - total = total_result.scalar() or 0 - - # 쿼리 2: Video + Project + comment_count 조회 (like_count는 Redis에서) - comment_count_subq = ( - select(func.count(Comment.id)) - .where( - Comment.video_id == Video.id, - Comment.is_deleted == False, # noqa: E712 - ) - .correlate(Video) - .scalar_subquery() - ) - data_query = ( - select( - Video, - Project, - comment_count_subq.label("comment_count"), - ) - .join(Project, Video.project_id == Project.id) - .where(Video.id.in_(select(latest_video_ids.c.latest_id))) - .order_by(Video.created_at.desc()) - .offset(offset) - .limit(pagination.page_size) - ) - result = await session.execute(data_query) - rows = result.all() - - # Redis mget으로 like_count 일괄 조회 - video_ids = [video.id for video, project, _ in rows] - like_count_map = await get_like_counts(video_ids) - - # 캐시 미스(None)인 video_id만 DB에서 보정 - missing_ids = [vid for vid, cnt in like_count_map.items() if cnt is None] - if missing_ids: - db_counts = (await session.execute( - select(VideoReaction.video_id, func.count(VideoReaction.id)) - .where(VideoReaction.video_id.in_(missing_ids)) - .group_by(VideoReaction.video_id) - )).all() - db_found_ids = set() - batch = {} - for vid, cnt in db_counts: - batch[vid] = cnt - like_count_map[vid] = cnt - db_found_ids.add(vid) - await mset_like_counts(batch) - for vid in missing_ids: - if vid not in db_found_ids: - like_count_map[vid] = 0 - - # VideoListItem으로 변환 items = [ VideoListItem( - video_id=video.id, - store_name=project.store_name, - region=project.region, - task_id=video.task_id, - result_movie_url=video.result_movie_url, - created_at=video.created_at, - like_count=like_count_map.get(video.id) or 0, - comment_count=comment_count or 0, + type=it.ctype, + video_id=it.id, + store_name=it.store_name, + region=it.region, + # 썰박스는 task_id 개념이 없어 빈 문자열이다. + # 프론트는 반드시 (type, video_id) 쌍으로 식별할 것. + task_id=it.task_id, + result_movie_url=it.movie_url, + created_at=it.created_at, + like_count=it.like_count, + comment_count=it.comment_count, ) - for video, project, comment_count in rows + for it in items ] response = PaginatedResponse.create( diff --git a/app/archive/worker/archive_task.py b/app/archive/worker/archive_task.py index 9c725d2..9435904 100644 --- a/app/archive/worker/archive_task.py +++ b/app/archive/worker/archive_task.py @@ -36,7 +36,7 @@ async def soft_delete_by_task_id(task_id: str) -> dict: dict: 각 테이블별 업데이트된 레코드 수 """ logger.info(f"[soft_delete_by_task_id] START - task_id: {task_id}") - logger.debug(f"[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작") + logger.debug("[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작") result = { "task_id": task_id, diff --git a/app/comment/models.py b/app/comment/models.py index bfb3b22..d184be1 100644 --- a/app/comment/models.py +++ b/app/comment/models.py @@ -1,12 +1,23 @@ from datetime import datetime from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Index, + Integer, + String, + func, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base if TYPE_CHECKING: + from app.ssulbox.models import SsulContent from app.user.models import User from app.video.models import Video @@ -18,11 +29,22 @@ class Comment(Base): 2-depth 구조 (최상위 댓글 + 대댓글 1단계). parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글. 작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책). + + **ADO2 영상과 썰박스 콘텐츠를 모두 담는다.** 대상은 `video_id` 또는 `content_id` + 중 **정확히 하나**만 채워지며, 이를 DB `CHECK` 로 강제한다. MySQL 은 하나의 FK 가 + 두 테이블을 조건부로 가리키게 할 수 없어 컬럼을 나눠 둔 것이다. + 별도의 종류 컬럼은 두지 않는다 — 어느 FK 가 채워졌는지가 곧 종류이고, + 컬럼을 하나 더 두면 둘이 어긋날 수 있다. """ __tablename__ = "comment" __table_args__ = ( + CheckConstraint( + "(video_id IS NULL) <> (content_id IS NULL)", + name="ck_comment_one_target", + ), Index("idx_comment_video_id", "video_id"), + Index("idx_comment_content_id", "content_id"), Index("idx_comment_user_uuid", "user_uuid"), Index("idx_comment_parent_id", "parent_id"), Index("idx_comment_is_deleted", "is_deleted"), @@ -36,11 +58,19 @@ class Comment(Base): id: Mapped[int] = mapped_column( Integer, primary_key=True, autoincrement=True, comment="고유 식별자" ) - video_id: Mapped[int] = mapped_column( + # 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_comment_one_target). + video_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("video.id", ondelete="CASCADE"), - nullable=False, - comment="연결된 Video의 id", + nullable=True, + comment="ADO2 영상 id (썰박스 댓글이면 NULL)", + ) + content_id: Mapped[Optional[int]] = mapped_column( + # ssul_content.id 는 BIGINT 다. INT 로 두면 FK 타입 불일치(errno 3780). + BigInteger, + ForeignKey("ssul_content.id", ondelete="CASCADE"), + nullable=True, + comment="썰박스 콘텐츠 id (ADO2 댓글이면 NULL)", ) user_uuid: Mapped[str] = mapped_column( ForeignKey("user.user_uuid", ondelete="CASCADE"), @@ -69,7 +99,13 @@ class Comment(Base): comment="작성 일시", ) - video: Mapped["Video"] = relationship("Video", back_populates="comments") + # 썰박스 댓글이면 None 이다. 접근하는 쪽에서 반드시 방어할 것. + video: Mapped[Optional["Video"]] = relationship( + "Video", foreign_keys=[video_id], back_populates="comments" + ) + content_ref: Mapped[Optional["SsulContent"]] = relationship( + "SsulContent", foreign_keys=[content_id], lazy="noload" + ) user: Mapped["User"] = relationship("User", back_populates="comments") parent: Mapped[Optional["Comment"]] = relationship( "Comment", remote_side=[id], back_populates="replies" diff --git a/app/core/common.py b/app/core/common.py index 9610cb9..6b284d1 100644 --- a/app/core/common.py +++ b/app/core/common.py @@ -6,6 +6,8 @@ from fastapi import FastAPI from app.utils.logger import get_logger from app.utils.nvMapPwScraper import NvMapPwScraper +from config import ssulbox_settings + logger = get_logger("core") @@ -29,6 +31,37 @@ async def lifespan(app: FastAPI): from app.dashboard.migration import init_dashboard_table await init_dashboard_table() + # 썰박스 스키마 보장 (모든 환경 - Alembic 부재 + create_db_tables 는 DEBUG 전용) + # 실패해도 앱 기동은 막지 않는다. 썰박스 스키마 문제로 castad 전체가 죽으면 안 된다. + 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 을 환불·정리. + # 기동 직후 인메모리 잡은 0개이므로 비터미널 잡은 전부 고아다. + # ⚠️ 이 불변식은 단일 워커 전제다(--workers 를 늘리면 정상 잡을 오판한다). + try: + from app.database.session import BackgroundSessionLocal + from app.ssulbox.services import task_service + + async with BackgroundSessionLocal() as session: + swept = await task_service.sweep_orphans(session) + await session.commit() + if swept: + logger.info(f"[ssulbox] 고아 잡 {swept}건 환불·정리") + except Exception as e: + logger.error( + f"[ssulbox] 고아 스윕 실패 (다음 기동 시 재시도): " + f"{type(e).__name__}: {e}" + ) + await NvMapPwScraper.initiate_scraper() except asyncio.TimeoutError: logger.error("Database initialization timed out") @@ -44,6 +77,16 @@ async def lifespan(app: FastAPI): # Shutdown - 애플리케이션 종료 시 logger.info("Shutting down...") + # 썰박스 신규 잡 큐잉 차단. dispose_engine() 전에 해야 워커 스레드가 + # 죽은 커넥션에 접근하지 않는다. 진행 중이던 잡은 다음 기동의 스윕이 환불한다. + if ssulbox_settings.SSULBOX_ENABLED: + try: + from app.ssulbox.worker import job_manager + + job_manager.shutdown() + except Exception as e: + logger.warning(f"[ssulbox] 종료 처리 실패: {e}") + # 공유 HTTP 클라이언트 종료 from app.utils.creatomate import close_shared_client from app.utils.upload_blob_as_request import close_shared_blob_client diff --git a/app/core/exceptions.py b/app/core/exceptions.py index 2790bd4..fc0fda6 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -326,6 +326,25 @@ def add_exception_handlers(app: FastAPI): }, ) + # SsulboxException 핸들러 추가 + # (FastShipError 를 상속하지 않으므로 위 자동 등록에 잡히지 않는다 — + # DashboardException 과 같은 (message, status_code, code) 형태를 쓴다) + from app.ssulbox.exceptions import SsulboxException + + @app.exception_handler(SsulboxException) + def ssulbox_exception_handler(request: Request, exc: SsulboxException) -> Response: + if exc.status_code < 500: + logger.warning(f"Handled SsulboxException: {exc.__class__.__name__} - {exc.message}") + else: + logger.error(f"Handled SsulboxException: {exc.__class__.__name__} - {exc.message}") + return JSONResponse( + status_code=exc.status_code, + content={ + "detail": exc.message, + "code": exc.code, + }, + ) + @app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR) def internal_server_error_handler(request, exception): # 에러 메시지 로깅 (한글 포함 가능) diff --git a/app/credit/exceptions.py b/app/credit/exceptions.py index 3cef871..6d0fd96 100644 --- a/app/credit/exceptions.py +++ b/app/credit/exceptions.py @@ -6,7 +6,9 @@ from app.core.exceptions import FastShipError class InsufficientCreditError(FastShipError): """크레딧이 부족합니다.""" - status = status.HTTP_400_BAD_REQUEST + # 402 Payment Required. 사전차감 전환 전에는 라우터로 노출된 적이 없어(내부에서만 + # raise/catch) 400 이었다. 프론트가 "충전 화면으로 유도"를 402 로 분기한다. + status = status.HTTP_402_PAYMENT_REQUIRED class InvalidRequestStateError(FastShipError): diff --git a/app/credit/models.py b/app/credit/models.py index 09834a1..276f3c4 100644 --- a/app/credit/models.py +++ b/app/credit/models.py @@ -2,7 +2,16 @@ from datetime import datetime from enum import Enum from typing import TYPE_CHECKING, Optional -from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, func +from sqlalchemy import ( + BigInteger, + DateTime, + ForeignKey, + Index, + Integer, + String, + UniqueConstraint, + func, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base @@ -146,6 +155,10 @@ class CreditTransaction(Base): Index("idx_credit_tx_user_uuid_created", "user_uuid", "created_at"), Index("idx_credit_tx_type", "type"), Index("idx_credit_tx_related_request", "related_request_id"), + # 작업 1건당 consume 1행 / refund 1행을 DB 레벨에서 보장하는 멱등 키. + # MySQL 은 NULL 을 서로 다르게 취급하므로 job_type 이 NULL 인 기존 + # charge/admin_adjust 행은 이 제약의 영향을 받지 않는다. + UniqueConstraint("job_type", "job_ref", "type", name="uq_credit_job"), { "mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", @@ -206,6 +219,25 @@ class CreditTransaction(Base): comment="연관 충전 요청 ID", ) + # ========================================================================== + # 작업 기반 멱등 키 — (job_type, job_ref, type) 유니크 + # ========================================================================== + # 사전차감 정책의 핵심. 생성 작업을 시작할 때 차감하고 실패 시 환불하는데, + # 재시도·중복 요청·크래시 후 재처리에도 "작업 1건당 정확히 1번"을 보장해야 한다. + # FK 를 걸지 않는 이유: 썰박스(ssul_task.id 숫자)와 영상(video.task_id UUID7 문자열) + # 이라는 이질적인 두 작업 테이블을 하나의 컬럼이 가리키기 때문이다. + job_type: Mapped[Optional[str]] = mapped_column( + String(20), + nullable=True, + comment="차감 유발 작업 종류 (ssul/video). 충전·관리자 조정은 NULL", + ) + + job_ref: Mapped[Optional[str]] = mapped_column( + String(64), + nullable=True, + comment="작업 식별자 (ssul_task.id 문자열 또는 video.task_id)", + ) + created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, diff --git a/app/credit/services/credit_service.py b/app/credit/services/credit_service.py index bfbd174..ffd518b 100644 --- a/app/credit/services/credit_service.py +++ b/app/credit/services/credit_service.py @@ -32,6 +32,8 @@ async def record_transaction( reason: Optional[str] = None, admin_id: Optional[int] = None, related_request_id: Optional[int] = None, + job_type: Optional[str] = None, + job_ref: Optional[str] = None, ) -> CreditTransaction: tx = CreditTransaction( user_uuid=user_uuid, @@ -41,6 +43,8 @@ async def record_transaction( reason=reason, admin_id=admin_id, related_request_id=related_request_id, + job_type=job_type, + job_ref=job_ref, ) session.add(tx) await session.flush() @@ -123,6 +127,189 @@ async def deduct_credit( return tx +# ============================================================================= +# 작업 기반 차감/환불 (사전차감 정책) +# ============================================================================= +# 위의 charge_credit / deduct_credit 은 멱등성이 없어 재시도 시 중복 반영된다. +# 생성 작업처럼 "시작할 때 차감하고 실패하면 환불"하는 경로에서는 아래 두 함수를 쓴다. +# +# 멱등 보장 방식은 2중이다: +# 1) 먼저 (job_type, job_ref, type) 로 기존 행을 조회해 있으면 그대로 반환 +# 2) 경합으로 1)을 통과한 두 요청이 동시에 INSERT 하면 DB 유니크 제약이 막는다 +# 잔액 자체는 User 행을 with_for_update() 로 잠근 뒤 읽기→갱신하므로 경합에 안전하다. +# +# 두 함수 모두 **자체 commit 하지 않는다.** 호출부가 트랜잭션을 소유하고, +# 작업 행 생성과 차감을 한 트랜잭션으로 묶어 마지막에 한 번만 커밋해야 한다. + + +async def _find_job_transaction( + session: AsyncSession, + job_type: str, + job_ref: str, + type: CreditTransactionType, +) -> Optional[CreditTransaction]: + """(job_type, job_ref, type) 에 해당하는 기존 원장 행 조회""" + result = await session.execute( + select(CreditTransaction).where( + CreditTransaction.job_type == job_type, + CreditTransaction.job_ref == job_ref, + CreditTransaction.type == type, + ) + ) + return result.scalar_one_or_none() + + +async def deduct_credit_for_job( + *, + session: AsyncSession, + user_uuid: str, + amount: int, + job_type: str, + job_ref: str, + reason: Optional[str] = None, +) -> CreditTransaction: + """작업 시작 시점에 크레딧을 선차감한다. (job_type, job_ref) 기준 멱등. + + Args: + session: 호출부가 소유하는 세션. 이 함수는 commit 하지 않는다. + user_uuid: 사용자 UUID + amount: 차감할 크레딧 (양수) + job_type: 작업 종류 ("video" | "ssul") + job_ref: 작업 식별자 (video.task_id 또는 str(ssul_task.id)) + reason: 원장에 남길 사유 + + Returns: + 새로 만든 차감 원장 행. 이미 차감된 작업이면 기존 행을 그대로 반환한다. + + Raises: + InsufficientCreditError: 잔액 부족 (호출부에서 402 로 변환할 것) + UserNotFoundError: 사용자 없음 + """ + from app.user.models import User + + existing = await _find_job_transaction( + session, job_type, job_ref, CreditTransactionType.CONSUME + ) + if existing is not None: + logger.info( + f"[CREDIT] deduct skipped (already charged) " + f"job={job_type}:{job_ref} tx_id={existing.id}" + ) + return existing + + result = await session.execute( + select(User).where(User.user_uuid == user_uuid).with_for_update() + ) + user = result.scalar_one_or_none() + if user is None: + from app.user.services.auth import UserNotFoundError + + raise UserNotFoundError() + + if user.credits < amount: + logger.warning( + f"[CREDIT] insufficient credits user_uuid={user_uuid} " + f"credits={user.credits} requested={amount} job={job_type}:{job_ref}" + ) + raise InsufficientCreditError() + + user.credits = user.credits - amount + await session.flush() + + tx = await record_transaction( + session=session, + user_uuid=user_uuid, + amount=-amount, + balance_after=user.credits, + type=CreditTransactionType.CONSUME, + reason=reason, + job_type=job_type, + job_ref=job_ref, + ) + logger.info( + f"[CREDIT] deduct user_uuid={user_uuid} amount=-{amount} " + f"balance_after={user.credits} job={job_type}:{job_ref}" + ) + return tx + + +async def refund_credit_for_job( + *, + session: AsyncSession, + user_uuid: str, + amount: int, + job_type: str, + job_ref: str, + reason: Optional[str] = None, +) -> Optional[CreditTransaction]: + """작업 실패 시 선차감한 크레딧을 환불한다. (job_type, job_ref) 기준 멱등. + + 차감 기록이 없으면 환불하지 않는다 — 애초에 차감되지 않은 작업(예: 정책 전환 + 이전에 시작된 in-flight 작업)에 환불을 얹으면 크레딧이 늘어나기 때문이다. + + Args: + session: 호출부가 소유하는 세션. 이 함수는 commit 하지 않는다. + user_uuid: 사용자 UUID + amount: 환불할 크레딧 (양수) + job_type: 작업 종류 ("video" | "ssul") + job_ref: 작업 식별자 + reason: 원장에 남길 사유 + + Returns: + 새로 만든 환불 원장 행. 이미 환불했거나 차감 기록이 없으면 None. + """ + from app.user.models import User + + already = await _find_job_transaction( + session, job_type, job_ref, CreditTransactionType.REFUND + ) + if already is not None: + logger.info( + f"[CREDIT] refund skipped (already refunded) " + f"job={job_type}:{job_ref} tx_id={already.id}" + ) + return None + + consumed = await _find_job_transaction( + session, job_type, job_ref, CreditTransactionType.CONSUME + ) + if consumed is None: + logger.info( + f"[CREDIT] refund skipped (never charged) job={job_type}:{job_ref}" + ) + return None + + result = await session.execute( + select(User).where(User.user_uuid == user_uuid).with_for_update() + ) + user = result.scalar_one_or_none() + if user is None: + logger.warning( + f"[CREDIT] refund skipped (user not found) " + f"user_uuid={user_uuid} job={job_type}:{job_ref}" + ) + return None + + user.credits = user.credits + amount + await session.flush() + + tx = await record_transaction( + session=session, + user_uuid=user_uuid, + amount=amount, + balance_after=user.credits, + type=CreditTransactionType.REFUND, + reason=reason, + job_type=job_type, + job_ref=job_ref, + ) + logger.info( + f"[CREDIT] refund user_uuid={user_uuid} amount=+{amount} " + f"balance_after={user.credits} job={job_type}:{job_ref}" + ) + return tx + + async def approve_charge_request( *, session: AsyncSession, diff --git a/app/database/like_cache.py b/app/database/like_cache.py index 0289b22..959234a 100644 --- a/app/database/like_cache.py +++ b/app/database/like_cache.py @@ -5,11 +5,19 @@ Write-Behind 패턴 적용: - 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시 - 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write +**콘텐츠 종류(ctype)** 를 받아 ADO2 영상과 썰박스 콘텐츠 양쪽에 같은 로직을 쓴다. +`ctype` 기본값이 "video" 라서 기존 castad 호출부는 수정 없이 그대로 동작한다. +검증된 Lua 원자 토글은 키를 인자로 받으므로 변경하지 않았다. + Key 패턴: -- video:like:count:{video_id} INT — 좋아요 카운트 -- video:like:users:{video_id} SET — 좋아요 누른 user_uuid 목록 -- video:reaction:dirty SET — DB 동기화 대기 "{video_id}:{user_uuid}" -- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용) +- {ctype}:like:count:{content_id} INT — 좋아요 카운트 +- {ctype}:like:users:{content_id} SET — 좋아요 누른 user_uuid 목록 +- video:reaction:dirty SET — DB 동기화 대기 "{ctype}:{content_id}:{user_uuid}" +- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용) + +ctype="video" 일 때 카운트/유저 키가 기존과 **완전히 동일**하므로 캐시 이관이 필요 없다. +dirty SET 키 이름도 "video:" 접두를 유지한다 — 배포 순간 큐에 남아 있는 항목을 +잃지 않기 위해서다(이름을 바꾸면 그 항목들이 영구 미반영된다). 캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다. """ @@ -44,6 +52,17 @@ end _DIRTY_KEY = "video:reaction:dirty" _DIRTY_PROCESSING_KEY = "video:reaction:dirty:processing" +# ────────────────────────────────────────────── +# 콘텐츠 종류 +# ────────────────────────────────────────────── +#: ADO2 영상 (video 테이블 · video_reaction) +CT_VIDEO = "video" +#: 썰박스 콘텐츠 (ssul_content 테이블 · ssul_like) +CT_SSUL = "ssul" + +#: dirty 항목 파싱 시 "종류 접두인지" 판별하는 데 쓴다 +CONTENT_TYPES: tuple[str, ...] = (CT_VIDEO, CT_SSUL) + def get_like_cache() -> aioredis.Redis: global _client @@ -68,61 +87,71 @@ async def close_like_cache() -> None: # Key 헬퍼 # ────────────────────────────────────────────── -def _key(video_id: int) -> str: - return f"video:like:count:{video_id}" +def _key(content_id: int, ctype: str = CT_VIDEO) -> str: + return f"{ctype}:like:count:{content_id}" -def _user_key(video_id: int) -> str: - return f"video:like:users:{video_id}" +def _user_key(content_id: int, ctype: str = CT_VIDEO) -> str: + return f"{ctype}:like:users:{content_id}" # ────────────────────────────────────────────── # 카운트 (기존 API 유지) # ────────────────────────────────────────────── -async def get_like_count(video_id: int) -> int | None: +async def get_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int | None: """Redis에서 like_count 조회. 캐시 미스 시 None 반환.""" - val = await get_like_cache().get(_key(video_id)) + val = await get_like_cache().get(_key(content_id, ctype)) if val is None: return None return max(int(val), 0) -async def get_like_counts(video_ids: list[int]) -> dict[int, int | None]: - """여러 영상의 like_count를 한 번에 조회 (mget). - 캐시 미스인 video_id는 None으로 반환.""" - if not video_ids: +async def get_like_counts( + content_ids: list[int], *, ctype: str = CT_VIDEO +) -> dict[int, int | None]: + """여러 콘텐츠의 like_count를 한 번에 조회 (mget). + 캐시 미스인 content_id는 None으로 반환.""" + if not content_ids: return {} - keys = [_key(vid) for vid in video_ids] + keys = [_key(cid, ctype) for cid in content_ids] values = await get_like_cache().mget(*keys) return { - vid: max(int(v), 0) if v is not None else None - for vid, v in zip(video_ids, values) + cid: max(int(v), 0) if v is not None else None + for cid, v in zip(content_ids, values) } -async def set_like_count(video_id: int, count: int) -> None: +async def set_like_count( + content_id: int, count: int, *, ctype: str = CT_VIDEO +) -> None: """like_count를 Redis에 저장 (음수 방지).""" - await get_like_cache().set(_key(video_id), max(count, 0)) + await get_like_cache().set(_key(content_id, ctype), max(count, 0)) -async def mset_like_counts(counts: dict[int, int]) -> None: - """여러 영상의 like_count를 한 번에 저장 (mset).""" +async def mset_like_counts( + counts: dict[int, int], *, ctype: str = CT_VIDEO +) -> None: + """여러 콘텐츠의 like_count를 한 번에 저장 (mset).""" if not counts: return - await get_like_cache().mset({_key(vid): max(cnt, 0) for vid, cnt in counts.items()}) + await get_like_cache().mset( + {_key(cid, ctype): max(cnt, 0) for cid, cnt in counts.items()} + ) -async def incr_like_count(video_id: int) -> int: +async def incr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int: """like_count를 1 증가 후 반환.""" - return max(int(await get_like_cache().incr(_key(video_id))), 0) + return max(int(await get_like_cache().incr(_key(content_id, ctype))), 0) -async def decr_like_count(video_id: int) -> int: +async def decr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int: """like_count를 1 감소 후 반환 (음수 방지).""" - count = int(await get_like_cache().decr(_key(video_id))) + client = get_like_cache() + key = _key(content_id, ctype) + count = int(await client.decr(key)) if count < 0: - await get_like_cache().set(_key(video_id), 0) + await client.set(key, 0) return 0 return count @@ -131,7 +160,9 @@ async def decr_like_count(video_id: int) -> int: # 유저 SET (is_liked_by_me source of truth) # ────────────────────────────────────────────── -async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]: +async def toggle_like_atomic( + content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO +) -> tuple[bool, int]: """Lua 스크립트로 원자적 좋아요 토글. Returns: @@ -140,14 +171,16 @@ async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]: result = await get_like_cache().eval( _TOGGLE_LIKE_SCRIPT, 2, - _user_key(video_id), - _key(video_id), + _user_key(content_id, ctype), + _key(content_id, ctype), user_uuid, ) return bool(result[0]), int(result[1]) -async def is_user_liked(video_id: int, user_uuid: str) -> bool | None: +async def is_user_liked( + content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO +) -> bool | None: """Redis user-set에서 좋아요 여부 조회. Returns: @@ -155,59 +188,101 @@ async def is_user_liked(video_id: int, user_uuid: str) -> bool | None: None: user-set 키가 없음 (cold-start backfill 필요 신호) """ client = get_like_cache() - key = _user_key(video_id) + key = _user_key(content_id, ctype) if not await client.exists(key): return None return bool(await client.sismember(key, user_uuid)) -async def is_user_set_exists(video_id: int) -> bool: +async def is_user_set_exists(content_id: int, *, ctype: str = CT_VIDEO) -> bool: """Redis user-set 키 존재 여부 확인.""" - return bool(await get_like_cache().exists(_user_key(video_id))) + return bool(await get_like_cache().exists(_user_key(content_id, ctype))) async def bulk_is_user_liked( - video_ids: list[int], user_uuid: str + content_ids: list[int], user_uuid: str, *, ctype: str = CT_VIDEO ) -> dict[int, bool | None]: - """여러 영상의 is_liked 여부를 한 번에 조회 (pipeline). + """여러 콘텐츠의 is_liked 여부를 한 번에 조회 (pipeline). + + 통합 목록처럼 두 종류가 섞인 경우에는 **종류별로 나눠 각각 호출한다** — + 반환 키가 content_id 하나여서 종류가 다른 같은 id 를 구분할 수 없다. Returns: - {video_id: True/False} — user-set 키가 없는 영상은 None + {content_id: True/False} — user-set 키가 없는 항목은 None """ - if not video_ids: + if not content_ids: return {} client = get_like_cache() async with client.pipeline(transaction=False) as pipe: - for vid in video_ids: - pipe.exists(_user_key(vid)) - pipe.sismember(_user_key(vid), user_uuid) + for cid in content_ids: + pipe.exists(_user_key(cid, ctype)) + pipe.sismember(_user_key(cid, ctype), user_uuid) responses = await pipe.execute() return { - vid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None) - for i, vid in enumerate(video_ids) + cid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None) + for i, cid in enumerate(content_ids) } -async def backfill_user_set(video_id: int, user_uuids: list[str]) -> None: +async def backfill_user_set( + content_id: int, user_uuids: list[str], *, ctype: str = CT_VIDEO +) -> None: """DB에서 가져온 유저 목록을 Redis SET에 일괄 적재.""" if user_uuids: - await get_like_cache().sadd(_user_key(video_id), *user_uuids) + await get_like_cache().sadd(_user_key(content_id, ctype), *user_uuids) # ────────────────────────────────────────────── # Dirty SET (Write-Behind 큐) # ────────────────────────────────────────────── -async def mark_dirty(video_id: int, user_uuid: str) -> None: +async def mark_dirty( + content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO +) -> None: """DB 동기화 대기 목록에 추가.""" - await get_like_cache().sadd(_DIRTY_KEY, f"{video_id}:{user_uuid}") + await get_like_cache().sadd(_DIRTY_KEY, f"{ctype}:{content_id}:{user_uuid}") -async def drain_dirty() -> list[tuple[int, str]]: +def _parse_dirty(member: str) -> tuple[str, int, str] | None: + """dirty 항목 문자열 → (ctype, content_id, user_uuid). + + 두 형식을 모두 받는다: + - 현재: "{ctype}:{content_id}:{user_uuid}" + - 구형: "{content_id}:{user_uuid}" ← 종류 도입 전에 큐에 들어간 항목 + + **구형 관용이 필요한 이유**: 배포 순간 dirty SET 에 구형 항목이 남아 있다. + 새 파서가 이를 못 읽으면 그 좋아요는 DB 에 영구 미반영된다. + 구형은 ADO2 영상뿐이었으므로 CT_VIDEO 로 해석한다. + + 형식이 깨진 항목은 None 을 돌려 호출부가 건너뛰게 한다 — 하나 때문에 + 플러시 전체가 죽으면 큐가 무한히 쌓인다. + """ + parts = member.split(":", 2) + + # 길이가 아니라 **첫 토큰이 알려진 종류인지**로 판정한다. + # user_uuid 에 콜론이 있어도 구형이 3조각으로 보일 수 있다. + if len(parts) == 3 and parts[0] in CONTENT_TYPES: + ctype, id_str, user_uuid = parts + else: + ctype = CT_VIDEO + legacy = member.split(":", 1) + if len(legacy) != 2: + return None + id_str, user_uuid = legacy + + if not id_str.isdigit() or not user_uuid: + return None + return ctype, int(id_str), user_uuid + + +async def drain_dirty() -> list[tuple[str, int, str]]: """dirty SET을 processing으로 RENAME 후 전체 반환. 이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지. + + Returns: + [(ctype, content_id, user_uuid), ...] """ client = get_like_cache() @@ -223,10 +298,12 @@ async def drain_dirty() -> list[tuple[int, str]]: await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY) members = await client.smembers(_DIRTY_PROCESSING_KEY) - result = [] + result: list[tuple[str, int, str]] = [] for member in members: - vid_str, user_uuid = member.split(":", 1) - result.append((int(vid_str), user_uuid)) + parsed = _parse_dirty(member) + if parsed is None: + continue + result.append(parsed) return result diff --git a/app/database/session.py b/app/database/session.py index e405fef..58dca67 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -86,6 +86,10 @@ async def create_db_tables(): from app.dashboard.models import Dashboard # noqa: F401 from app.backoffice.admin.models import Admin # noqa: F401 from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401 + from app.ssulbox.models import ( # noqa: F401 + SsulContent, + SsulSocialUpload, + ) # 생성할 테이블 목록 (FK 순서: 참조 대상 먼저) tables_to_create = [ @@ -106,6 +110,9 @@ async def create_db_tables(): Admin.__table__, CreditChargeRequest.__table__, CreditTransaction.__table__, + # 썰박스 (FK 순서: ssul_content 를 나머지가 참조) + SsulContent.__table__, + SsulSocialUpload.__table__, ] logger.info("Creating database tables...") diff --git a/app/ssulbox/__init__.py b/app/ssulbox/__init__.py new file mode 100644 index 0000000..680b95c --- /dev/null +++ b/app/ssulbox/__init__.py @@ -0,0 +1,9 @@ +"""썰박스(ssulbox) 모듈. + +네이버 지도 링크나 업장명을 입력하면 4개 시나리오(조선왕·삼국지·그리스로마신화·오디세이) +중 하나로 대본 → 그림 → 목소리 → 영상을 자동 생성하는 병맛 역사 썰툰 쇼츠 파이프라인. + +별도 저장소(o2o-ssulbox)로 개발되던 것을 castad 백엔드로 이식했다. +계정·크레딧·소셜 계정은 castad 것을 그대로 쓰고(User / CreditTransaction / SocialAccount), +썰박스 고유 도메인만 `ssul_` 접두 테이블로 신설한다. +""" diff --git a/app/ssulbox/api/__init__.py b/app/ssulbox/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ssulbox/api/routers/__init__.py b/app/ssulbox/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ssulbox/api/routers/v1/__init__.py b/app/ssulbox/api/routers/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ssulbox/api/routers/v1/content.py b/app/ssulbox/api/routers/v1/content.py new file mode 100644 index 0000000..16a88a7 --- /dev/null +++ b/app/ssulbox/api/routers/v1/content.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +"""썰박스 API — 장소 검색 · 생성 요청 · 진행 폴링. + +castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `/ssul` 로 노출한다. +인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기). +""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select + +from app.credit.exceptions import InsufficientCreditError +from app.database.session import AsyncSessionLocal, get_session +from app.ssulbox.constants import ORPHAN_STATUSES, is_generation_available +from app.ssulbox.exceptions import GenerationUnavailableError, TaskNotFoundError +from app.ssulbox.models import SsulContent +from app.ssulbox.schemas.ssulbox_schema import ( + SsulActiveTasksResponse, + SsulCreateRequest, + SsulCreateResponse, + SsulPlaceSearchResponse, + SsulTaskStatus, +) +from app.ssulbox.services import place_service, task_service +from app.ssulbox.worker import job_manager +from app.user.dependencies.auth import get_current_user +from app.user.models import User +from app.utils.logger import get_logger +from config import ssulbox_settings +from sqlalchemy.ext.asyncio import AsyncSession + +logger = get_logger("ssulbox") + +router = APIRouter(prefix="/ssul", tags=["Ssulbox"]) + + +@router.get( + "/search/place", + response_model=SsulPlaceSearchResponse, + summary="업장 검색 (네이버 지도)", + description=""" +업장명으로 네이버 지도 후보를 검색합니다. + +castad `/search/accommodation`(네이버 **검색 API**)과 달리 **`place_url` 을 포함**합니다. +생성 파이프라인이 네이버 지도 place 페이지를 크롤링하므로 이 URL 이 필요합니다. + +- 동명 업장은 주소로 구분됩니다. +- 검색 실패·타임아웃 시 빈 목록을 반환합니다(사용자는 네이버 링크를 직접 붙여넣을 수 있습니다). +- Playwright 크롤링이라 수 초 걸립니다. +""", + responses={ + 200: {"description": "검색 성공 (결과 없음도 200)"}, + 401: {"description": "인증 실패"}, + }, +) +async def search_place( + query: str = Query(..., min_length=2, description="업장명"), + limit: int = Query(default=8, ge=1, le=20), + current_user: User = Depends(get_current_user), +) -> SsulPlaceSearchResponse: + items = await place_service.search_places(query, limit=limit) + return SsulPlaceSearchResponse(query=query, count=len(items), items=items) + + +@router.post( + "/create", + response_model=SsulCreateResponse, + summary="썰박스 생성 요청", + description=""" +썰박스 생성을 요청합니다. + +## 크레딧 +- **요청 시점에 크레딧이 선차감됩니다.** 완료 시점이 아닙니다. +- 생성이 실패하면 자동으로 환불됩니다. +- 잔액이 부족하면 402 를 반환하며 잡도 생성되지 않습니다. + +## 진행 확인 +응답의 `id` 로 `GET /ssul/tasks/{id}` 를 폴링하세요. +권장 간격은 응답의 `poll_interval_seconds` 입니다. +""", + responses={ + 200: {"description": "요청 접수"}, + 401: {"description": "인증 실패"}, + 402: {"description": "크레딧 부족"}, + 503: {"description": "생성 기능 비활성 (Gemini API 키 미설정)"}, + }, +) +async def create_ssul( + body: SsulCreateRequest, + current_user: User = Depends(get_current_user), +) -> SsulCreateResponse: + if not is_generation_available(): + raise GenerationUnavailableError("Gemini API 키가 설정되지 않았습니다.") + + # 행 삽입 + 크레딧 선차감을 한 트랜잭션으로 묶는다. + # 외부 API 호출이 없으므로 Depends(get_session) 대신 짧게 열고 닫는다. + async with AsyncSessionLocal() as session: + try: + row = await task_service.create_task( + session, + user_uuid=current_user.user_uuid, + scenario=body.scenario, + input_text=body.input, + scenes=body.scenes, + seconds=body.seconds, + store_name=body.store_name, + road_address=body.road_address, + address=body.address, + ) + await session.commit() + content_id = row.id + except InsufficientCreditError: + await session.rollback() + logger.info( + f"[create_ssul] INSUFFICIENT CREDIT user={current_user.user_uuid}" + ) + raise + except Exception: + await session.rollback() + raise + + # 커밋이 끝난 뒤에 큐잉한다 — 워커가 아직 없는 행을 조회하면 안 된다. + # create_job 은 앱 이벤트 루프를 캡처하므로 반드시 요청 핸들러에서 호출한다. + job_manager.create_job( + content_id, body.scenario, body.input, body.scenes, body.seconds + ) + + return SsulCreateResponse( + id=content_id, + status="queued", + poll_interval_seconds=ssulbox_settings.SSULBOX_POLL_HINT_SECONDS, + ) + + +@router.get( + "/tasks/active", + response_model=SsulActiveTasksResponse, + summary="진행 중인 내 생성 잡", + description=""" +새로고침·새 탭 진입 시 진행 상태를 복구하는 데 씁니다. + +클라이언트의 localStorage 는 새 탭에서 초기화되므로 **서버가 권위**입니다. +""", +) +async def get_active_tasks( + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> SsulActiveTasksResponse: + rows = ( + ( + await session.execute( + select(SsulContent) + .where( + SsulContent.user_uuid == current_user.user_uuid, + SsulContent.status.in_(ORPHAN_STATUSES), + SsulContent.is_deleted.is_(False), + ) + .order_by(SsulContent.created_at.desc()) + ) + ) + .scalars() + .all() + ) + return SsulActiveTasksResponse( + items=[SsulTaskStatus.model_validate(r) for r in rows] + ) + + +@router.get( + "/tasks/{content_id}", + response_model=SsulTaskStatus, + summary="생성 진행 상태 (폴링)", + description=""" +생성 진행 상태를 반환합니다. 프론트가 3초마다 폴링합니다. + +- `step` 은 완료한 단계 수(0~4)입니다. 0=준비, 4=영상 합성 완료. +- `video_url` 은 `status=done` 일 때만 채워집니다. +- 남의 잡은 404 로 처리합니다(존재 여부를 노출하지 않습니다). +""", + responses={ + 200: {"description": "조회 성공"}, + 401: {"description": "인증 실패"}, + 404: {"description": "잡을 찾을 수 없음"}, + }, +) +async def get_task( + content_id: int, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> SsulTaskStatus: + row = await session.get(SsulContent, content_id) + # 남의 잡이면 존재 여부를 알리지 않고 동일하게 404 + if row is None or row.user_uuid != current_user.user_uuid: + raise TaskNotFoundError() + return SsulTaskStatus.model_validate(row) diff --git a/app/ssulbox/constants.py b/app/ssulbox/constants.py new file mode 100644 index 0000000..0df1a55 --- /dev/null +++ b/app/ssulbox/constants.py @@ -0,0 +1,110 @@ +"""썰박스 상수. + +설정(SsulboxSettings)이 아니라 코드와 함께 고정되는 값들. 시나리오 ↔ 엔진 폴더 매핑, +생성 잡 stdout 마커 파싱 규칙, 태스크 상태 enum이 여기 있다. +""" + +import re +from enum import Enum +from typing import Final + +from config import apikey_settings + +# ============================================================================= +# 시나리오 ↔ 생성 엔진 폴더 +# ============================================================================= +# 프론트가 보내는 시나리오 코드를 generator/ 하위 엔진 디렉터리명으로 옮긴다. +# 엔진을 추가하면 여기와 프론트 ssulData.ts 양쪽을 함께 고쳐야 한다. +SCENARIO_ENGINE: Final[dict[str, str]] = { + "joseon": "animation", + "samgukji": "animation_samgukji", + "greek": "animation_greekroman", + "odyssey": "animation_odyssey", +} + +ENGINE_SCENARIO: Final[dict[str, str]] = {v: k for k, v in SCENARIO_ENGINE.items()} + +SCENARIOS: Final[tuple[str, ...]] = tuple(SCENARIO_ENGINE) + + +# ============================================================================= +# 생성 잡 상태 +# ============================================================================= +class SsulTaskStatus(str, Enum): + """생성 잡의 권위 상태. 인메모리 진행률(step)과 달리 DB가 진실 원천이다.""" + + QUEUED = "queued" + RUNNING = "running" + DONE = "done" + ERROR = "error" + + +#: 프로세스 기동 시 고아로 판정해 환불·정리할 상태들 +ORPHAN_STATUSES: Final[tuple[str, ...]] = ( + SsulTaskStatus.QUEUED.value, + SsulTaskStatus.RUNNING.value, +) + + +# ============================================================================= +# 생성 엔진 stdout 파싱 +# ============================================================================= +#: 엔진이 뱉는 진행 마커. 예) "[2/4] 스토리보드 생성" +STEP_RE: Final[re.Pattern[str]] = re.compile(r"\[(\d)\s*/\s*4\]") + +#: 완료 마커. 예) "완성: output/animation/xxx/final.mp4" +DONE_RE: Final[re.Pattern[str]] = re.compile(r"완성[::]\s*(.+\.mp4)") + +#: 업장명 마커. 예) "■ 가게: 골목냉면 / 소재 5줄 → 키워드로 사용" +#: place URL 을 직접 붙여넣어 검색을 거치지 않은 경우, 업장명을 얻을 수 있는 +#: 유일한 경로다(엔진이 네이버 브리핑에서 뽑아 찍는다). +#: 주소는 찍지 않으므로 이 경로에서는 region 을 채울 수 없다. +STORE_RE: Final[re.Pattern[str]] = re.compile(r"가게\s*[::]\s*([^/\n]+?)\s*(?:/|$)") + +#: 작업 폴더 마커. 예) "■ 작업 폴더 : C:\...\output\animation\골목냉면20260729" +#: 실패 시 정리할 대상을 알아내는 유일한 수단이다 — 실패하면 완료 마커가 +#: 없어 mp4 경로로 폴더를 역산할 수 없다. +JOB_DIR_RE: Final[re.Pattern[str]] = re.compile(r"작업\s*폴더\s*[::]\s*(.+)") + +#: step 1~4에 대응하는 사람이 읽는 단계명 (프론트 진행 표시용) +STEP_NAMES: Final[tuple[str, ...]] = ("대본 생성", "스토리보드", "이미지·음성", "영상 합성") + +TOTAL_STEPS: Final[int] = len(STEP_NAMES) + + +# ============================================================================= +# 크레딧 원장 멱등 키 +# ============================================================================= +#: credit_transaction.job_type 값. (job_type, job_ref, type) 유니크로 중복 차감을 막는다. +JOB_TYPE_SSUL: Final[str] = "ssul" +JOB_TYPE_VIDEO: Final[str] = "video" + + +# ============================================================================= +# Gemini API 키 판정 +# ============================================================================= +#: castad config 의 GEMINI_API_KEY 기본값이 플레이스홀더 문자열이라 +#: `if not key` 형태의 가드가 항상 통과해버린다. 반드시 gemini_key() 로 판정할 것. +_PLACEHOLDER_KEYS: Final[frozenset[str]] = frozenset( + { + "", + "your-gemeni-api-key", # config.py 기본값 (오타 그대로) + "your-gemini-api-key", + "none", + } +) + + +def gemini_key() -> str | None: + """실제로 사용 가능한 Gemini API 키를 반환한다. 미설정이면 None. + + Returns: + 설정된 키. 플레이스홀더이거나 비어 있으면 None. + """ + key = (apikey_settings.GEMINI_API_KEY or "").strip() + return None if key.lower() in _PLACEHOLDER_KEYS else key + + +def is_generation_available() -> bool: + """생성 파이프라인을 돌릴 수 있는 상태인지 여부.""" + return gemini_key() is not None diff --git a/app/ssulbox/exceptions.py b/app/ssulbox/exceptions.py new file mode 100644 index 0000000..144cdf2 --- /dev/null +++ b/app/ssulbox/exceptions.py @@ -0,0 +1,146 @@ +"""썰박스 예외. + +app/dashboard/exceptions.py 와 동일한 (message, status_code, code) 형태를 따른다. +전역 핸들러가 code 를 그대로 응답 본문에 실어 보내므로, 프론트가 분기에 쓰는 +문자열이다 — 이미 나간 code 값은 함부로 바꾸지 말 것. +""" + +from fastapi import status + + +class SsulboxException(Exception): + """썰박스 기본 예외""" + + def __init__( + self, + message: str, + status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, + code: str = "SSULBOX_ERROR", + ): + self.message = message + self.status_code = status_code + self.code = code + super().__init__(self.message) + + +# ============================================================================= +# 생성 요청 관련 +# ============================================================================= + + +class InvalidScenarioError(SsulboxException): + """지원하지 않는 시나리오 코드""" + + def __init__(self, scenario: str = ""): + message = "지원하지 않는 시나리오입니다." + if scenario: + message += f" ({scenario})" + super().__init__( + message=message, + status_code=status.HTTP_400_BAD_REQUEST, + code="SSUL_INVALID_SCENARIO", + ) + + +class GenerationUnavailableError(SsulboxException): + """생성 엔진 사용 불가 (Gemini API 키 미설정 등)""" + + def __init__(self, detail: str = ""): + message = "썰박스 생성 기능이 현재 비활성화되어 있습니다." + if detail: + message += f" ({detail})" + super().__init__( + message=message, + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + code="SSUL_GENERATION_UNAVAILABLE", + ) + + +class TaskAlreadyRunningError(SsulboxException): + """이미 진행 중인 생성 잡이 있음""" + + def __init__(self): + super().__init__( + message="이미 생성 중인 썰박스가 있습니다. 완료 후 다시 시도해주세요.", + status_code=status.HTTP_409_CONFLICT, + code="SSUL_TASK_ALREADY_RUNNING", + ) + + +# ============================================================================= +# 조회 관련 +# ============================================================================= + + +class TaskNotFoundError(SsulboxException): + """생성 잡을 찾을 수 없음 (없거나 남의 것)""" + + def __init__(self): + super().__init__( + message="생성 요청을 찾을 수 없습니다.", + status_code=status.HTTP_404_NOT_FOUND, + code="SSUL_TASK_NOT_FOUND", + ) + + +class ContentNotFoundError(SsulboxException): + """콘텐츠를 찾을 수 없음""" + + def __init__(self): + super().__init__( + message="썰박스 콘텐츠를 찾을 수 없습니다.", + status_code=status.HTTP_404_NOT_FOUND, + code="SSUL_CONTENT_NOT_FOUND", + ) + + +class CommentNotFoundError(SsulboxException): + """댓글을 찾을 수 없음""" + + def __init__(self): + super().__init__( + message="댓글을 찾을 수 없습니다.", + status_code=status.HTTP_404_NOT_FOUND, + code="SSUL_COMMENT_NOT_FOUND", + ) + + +class CommentDepthExceededError(SsulboxException): + """댓글은 2-depth(댓글 + 대댓글)까지만 허용""" + + def __init__(self): + super().__init__( + message="대댓글에는 답글을 달 수 없습니다.", + status_code=status.HTTP_400_BAD_REQUEST, + code="SSUL_COMMENT_DEPTH_EXCEEDED", + ) + + +# ============================================================================= +# 생성 실행 관련 +# ============================================================================= + + +class GenerationFailedError(SsulboxException): + """생성 엔진 실행 실패""" + + def __init__(self, detail: str = ""): + message = "썰박스 생성에 실패했습니다." + if detail: + message += f" ({detail})" + super().__init__( + message=message, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + code="SSUL_GENERATION_FAILED", + ) + + +class GenerationTimeoutError(SsulboxException): + """생성 엔진 실행 시간 초과""" + + def __init__(self): + super().__init__( + message="썰박스 생성 시간이 초과되었습니다. 크레딧은 환불됩니다.", + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + code="SSUL_GENERATION_TIMEOUT", + ) diff --git a/app/ssulbox/migration.py b/app/ssulbox/migration.py new file mode 100644 index 0000000..434c226 --- /dev/null +++ b/app/ssulbox/migration.py @@ -0,0 +1,419 @@ +"""썰박스 스키마 보장. + +**이 프로젝트에는 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 new file mode 100644 index 0000000..ee352bf --- /dev/null +++ b/app/ssulbox/models.py @@ -0,0 +1,445 @@ +"""썰박스 SQLAlchemy 모델. + +castad 와 겹치는 테이블은 **신설하지 않고** castad 것을 그대로 쓴다 +(user / credit_transaction / social_account, 그리고 2026-07-30 부터 comment / +video_reaction). 그 테이블들은 `video_id` 와 `content_id` 를 모두 nullable 로 두고 +**정확히 하나만** 채우도록 CHECK 로 강제한다. + +썰박스 고유 도메인(`ssul_content`)만 `ssul_` 접두로 남는다. + +컨벤션은 castad 를 따른다: BigInteger PK, user_uuid(String36) 기준 FK, +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 에 반영된다. +""" + +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import ( + BigInteger, + Boolean, + DateTime, + ForeignKey, + Index, + Integer, + JSON, + String, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + +if TYPE_CHECKING: + from app.user.models import SocialAccount + +# MySQL 전용 테이블 옵션 (castad 공통) +_MYSQL_OPTS = { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", +} + + +class SsulContent(Base): + """썰박스 1편 — 생성 요청부터 완성까지 한 행으로 관리한다. + + **castad `Video` 와 같은 구조다.** castad 도 "영상 생성 잡"과 "완성된 영상"을 + 나누지 않고 `video` 한 테이블에 status / result_movie_url 을 함께 둔다. + 원본 썰박스는 Task 와 Content 를 나눴지만, 1:1 이면서 목록의 필터(user_uuid, + scenario)와 정렬(created_at)이 서로 다른 테이블에 흩어져 조인 비용이 컸다 + (측정: 소유 비율에 따라 18~22ms, 병합 시 1ms 수준). + + 라이프사이클: + 1. 요청 → INSERT (status=queued, video_url=NULL) + 크레딧 선차감 + 2. 엔진 실행 → UPDATE status/step + 3. 완료 → UPDATE video_url/store_name/region, status=done + 4. 실패 → UPDATE status=error, error, 크레딧 환불 + + 3번이 INSERT 가 아니라 UPDATE 라 finalize 가 자연히 멱등이다. + + 목록 조회는 castad `/video/all` 과 동일하게 완성분만 거른다: + WHERE is_deleted=0 AND status='done' AND video_url IS NOT NULL + + id 가 크레딧 원장 멱등 키(job_type='ssul', job_ref=str(id))의 앵커다. + """ + + __tablename__ = "ssul_content" + __table_args__ = ( + # 필터와 정렬이 같은 테이블에 있으므로 복합 인덱스 하나로 filesort 없이 처리된다. + # 전체 목록 (완성분만, 최신순) + Index("idx_ssul_content_list", "is_deleted", "status", "created_at"), + # 내 콘텐츠 + Index("idx_ssul_content_user_created", "user_uuid", "created_at"), + # 시나리오 필터 + Index("idx_ssul_content_scen_created", "scenario", "created_at"), + # 고아 스윕 (기동 시 queued/running 조회) + Index("idx_ssul_content_status", "status"), + _MYSQL_OPTS, + ) + + id: Mapped[int] = mapped_column( + BigInteger, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자 (크레딧 원장 job_ref 앵커)", + ) + + # castad `project.user_uuid` 와 동일한 정책(SET NULL). + # 탈퇴해도 콘텐츠는 남고 소유자만 비워진다. + user_uuid: Mapped[Optional[str]] = mapped_column( + String(36), + ForeignKey("user.user_uuid", ondelete="SET NULL"), + nullable=True, + comment="생성 요청한 사용자 UUID (탈퇴 시 NULL)", + ) + + # ========================================================================== + # 생성 요청 정보 + # ========================================================================== + scenario: Mapped[str] = mapped_column( + String(20), + nullable=False, + comment="시나리오 코드 (joseon/samgukji/greek/odyssey)", + ) + + input: Mapped[str] = mapped_column( + Text, + nullable=False, + comment="입력값 (네이버 지도 URL 또는 업장명)", + ) + + # scenes / seconds 는 DB 기본값을 두지 않는다. 기본값(9 / 30)과 허용 범위 + # (4~20 / 20~90)는 Pydantic 요청 스키마에서 Field(default, ge, le)로 강제한다. + scenes: Mapped[int] = mapped_column( + Integer, + nullable=False, + comment="생성할 장면 수 (요청 스키마에서 4~20 제한, 기본 9)", + ) + + seconds: Mapped[int] = mapped_column( + Integer, + nullable=False, + comment="장면당 초 길이 (요청 스키마에서 20~90 제한, 기본 30)", + ) + + # ========================================================================== + # 생성 잡 상태 + # ========================================================================== + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="queued", + server_default="queued", + comment="상태 (queued/running/done/error). 목록에는 done 만 노출", + ) + + step: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + server_default="0", + comment="진행 단계 0~4 (폴링 응답용. 0=준비, 4=영상 합성 완료)", + ) + + error: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="실패 사유", + ) + + # ========================================================================== + # 산출물 (완료 시 채워짐) + # ========================================================================== + video_url: Mapped[Optional[str]] = mapped_column( + String(500), + nullable=True, + comment="완성 영상 URL (Azure Blob 공개 URL 또는 로컬 서빙 경로)", + ) + + thumbnail_url: Mapped[Optional[str]] = mapped_column( + String(500), + nullable=True, + comment="썸네일 URL (없으면 프론트가 시나리오 표지로 대체)", + ) + + # ========================================================================== + # 목록 표시 (크롤링 후 채워짐) — castad video 는 Project 에서 가져오는 값들 + # ========================================================================== + # castad `project.store_name` 과 동일하게 varchar(255) NOT NULL. + # 통합 목록이 이 컬럼을 UNION 하므로 타입·널 허용이 어긋나면 정렬·비교에서 + # 미묘한 차이가 생긴다. + # + # 단 하나 다른 점: **server_default 가 빈 문자열**이다. castad `project` 는 + # 크롤링이 끝난 뒤 생성되어 업장명을 이미 알지만, 썰박스는 요청 즉시 행을 만들고 + # (크레딧 선차감 때문) 업장명은 그 뒤 크롤링으로 채운다. 기본값이 없으면 + # 생성 자체가 불가능하다. 빈 문자열은 "아직 모름"을 뜻하며, 채우는 쪽은 + # falsy 검사로 판단한다(`set_place_info`). + store_name: Mapped[str] = mapped_column( + String(255), + nullable=False, + default="", + server_default="", + comment="대상 업장명 (통합 목록에서 castad video.store_name 자리에 대응)", + ) + + region: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + comment="지역 (통합 목록의 지역 필터에 사용)", + ) + + # castad `project.detail_region_info` 와 동일한 역할·타입(TEXT NULL). + # 지역 필터가 `region` 만 보지 않고 **상세 주소의 별칭까지 부분 일치**로 훑기 + # 때문에(`/video/all` 의 SIDO_SEARCH_ALIASES), 이 값이 없으면 썰박스는 + # `region IN (cities)` 경로로만 걸려 castad 와 필터 결과가 비대칭이 된다. + detail_region_info: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용", + ) + + # title / caption / views / like_count / comment_count 는 두지 않는다. + # - castad `video` 도 제목을 갖지 않고 목록 표시는 store_name 으로 한다. + # SNS 업로드 제목·설명은 업로드 시점에 작성해 ssul_social_upload 에 담고, + # 다운로드 파일명은 프론트가 정한다. + # - 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다 + # (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다). + # 카운터를 들면 쓰기 경로마다 갱신해야 하고 드리프트가 생긴다. + + is_deleted: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + server_default="0", + comment="소프트 삭제 여부", + ) + + # updated_at 은 **보류**다. 일반론으로는 이런 가변 테이블(queued→running→step→done)에 + # 두는 것이 맞고, subprocess 가 멈출 수 있어 "오래 안 움직인 잡 찾기"에도 유용하다. + # 다만 castad `video`/`comment` 에 없어 썰박스만 갖는 게 비대칭이라 미뤘다. + # → ADO2 쪽에 추가할 때 여기도 함께 넣는다(nullable DDL 이라 무중단 가능). + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="생성 요청 일시 (목록 정렬 기준)", + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + +# 좋아요·댓글 모델은 여기 없다. +# castad `video_reaction` / `comment` 에 합쳤다(2026-07-30) — 그쪽 행은 +# ADO2 면 video_id, 썰박스면 content_id 가 채워지고 CHECK 로 하나만 강제한다. +# 합친 이유: 네 테이블이 모두 0행이라 이관 비용이 없었고, `like_cache` 가 이미 +# 종류별 키를 지원해 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"" + ) diff --git a/app/ssulbox/schemas/__init__.py b/app/ssulbox/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ssulbox/schemas/ssulbox_schema.py b/app/ssulbox/schemas/ssulbox_schema.py new file mode 100644 index 0000000..d78b743 --- /dev/null +++ b/app/ssulbox/schemas/ssulbox_schema.py @@ -0,0 +1,117 @@ +"""썰박스 API 요청/응답 스키마 (Pydantic v2).""" + +from datetime import datetime +from typing import Literal, Optional + +from pydantic import BaseModel, Field + +from app.ssulbox.constants import SCENARIOS + +ScenarioLiteral = Literal["joseon", "samgukji", "greek", "odyssey"] + + +# ============================================================================= +# 장소 검색 +# ============================================================================= +class SsulPlaceItem(BaseModel): + """네이버 지도 검색 후보. + + castad `/search/accommodation` 응답과 달리 **place_url 을 포함**한다. + generator 가 이 URL 로 place 페이지를 크롤링하기 때문이다. + """ + + title: str = Field(..., description="업장명") + category: str = Field(default="", description="업종") + address: str = Field(default="", description="표시용 주소 (동명 업장 구분에 사용)") + roadAddress: str = Field(default="", description="도로명 주소") + place_url: str = Field(..., description="네이버 지도 place URL (생성 파이프라인 입력)") + + +class SsulPlaceSearchResponse(BaseModel): + query: str + count: int + items: list[SsulPlaceItem] + + +# ============================================================================= +# 생성 요청 +# ============================================================================= +class SsulCreateRequest(BaseModel): + """생성 요청. + + `scenes`/`seconds` 의 기본값과 허용 범위는 **여기서만** 강제한다. + DB 에 기본값을 두면 진실이 두 곳에 생기므로 모델에는 두지 않았다. + """ + + scenario: ScenarioLiteral = Field(..., description="시나리오 코드") + input: str = Field( + ..., + min_length=2, + max_length=500, + description="네이버 지도 place URL 또는 업장명", + ) + scenes: int = Field(default=9, ge=4, le=20, description="장면 수") + seconds: int = Field(default=30, ge=20, le=90, description="장면당 초 길이") + + # 검색으로 업장을 고른 경우 프론트가 함께 보낸다(`/ssul/search/place` 결과). + # 통합 목록의 업장명 표시와 store_name/region 필터가 이 값에 의존한다. + # place URL 을 직접 붙여넣은 경우에는 없으며, 그때 store_name 은 생성 로그의 + # `■ 가게:` 마커로 뒤늦게 채운다(region 은 주소가 없어 채울 수 없다). + store_name: str | None = Field( + default=None, max_length=200, description="업장명 (검색 선택 시)" + ) + # 도로명·지번을 모두 받는다. castad `/home/crawl` 과 같이 도로명에서 시/군 추출이 + # 실패하면 지번으로 재시도해야 지역이 비는 경우를 줄인다. + road_address: str | None = Field( + default=None, + max_length=300, + description="도로명 주소 (검색 선택 시). region 추출에만 쓰고 저장하지 않는다", + ) + address: str | None = Field( + default=None, + max_length=300, + description="지번 주소 (검색 선택 시). 도로명 추출 실패 시 폴백", + ) + + +class SsulCreateResponse(BaseModel): + id: int = Field(..., description="생성 잡 ID (폴링·크레딧 원장 앵커)") + status: str = Field(..., description="queued") + poll_interval_seconds: int = Field( + ..., description="권장 폴링 간격(초). 클라이언트가 참고한다" + ) + + +# ============================================================================= +# 진행 상태 (폴링) +# ============================================================================= +class SsulTaskStatus(BaseModel): + """`GET /ssul/tasks/{id}` 응답. 프론트가 3초마다 폴링한다.""" + + id: int + scenario: str + status: Literal["queued", "running", "done", "error"] + step: int = Field(..., ge=0, le=4, description="완료한 단계 수 (0=준비, 4=합성 완료)") + error: Optional[str] = None + video_url: Optional[str] = Field(None, description="완료 시에만 채워진다") + created_at: datetime + + model_config = {"from_attributes": True} + + +class SsulActiveTasksResponse(BaseModel): + """진행 중인 내 잡. 새로고침·새 탭 복구에 쓴다""" + + items: list[SsulTaskStatus] + + +__all__ = [ + "SCENARIOS", + "ScenarioLiteral", + "SsulActiveTasksResponse", + "SsulCreateRequest", + "SsulCreateResponse", + "SsulPlaceItem", + "SsulPlaceSearchResponse", + "SsulTaskStatus", +] diff --git a/app/ssulbox/services/__init__.py b/app/ssulbox/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ssulbox/services/blob_service.py b/app/ssulbox/services/blob_service.py new file mode 100644 index 0000000..adb72ec --- /dev/null +++ b/app/ssulbox/services/blob_service.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""썰박스 산출물 Blob 업로드. + +castad `AzureBlobUploader` 를 그대로 재사용한다 — 원본 썰박스의 `blob_client.py` +는 이식하지 않는다. 같은 Azure 계정을 쓰므로 업로더를 두 벌 둘 이유가 없다. + +경로는 `{user_uuid}/{task_id}/video/{file}` 형태가 되는데, ADO2 콘텐츠와 섞이지 않도록 +task_id 자리에 `ssulbox-{id}` 접두를 붙인다. +""" + +from pathlib import Path +from typing import Optional + +from app.utils.logger import get_logger +from app.utils.upload_blob_as_request import AzureBlobUploader +from config import azure_blob_settings, ssulbox_settings + +logger = get_logger("ssulbox") + +#: 설정되지 않았을 때의 플레이스홀더 (config.py 기본값) +_PLACEHOLDER_SAS = {"", "your-sas-token", "none"} + + +def blob_enabled() -> bool: + """Blob 업로드가 가능한 상태인지. + + 비활성이면 로컬 파일을 그대로 서빙한다(개발 환경). + """ + token = (azure_blob_settings.AZURE_BLOB_SAS_TOKEN or "").strip() + return token.lower() not in _PLACEHOLDER_SAS + + +async def upload_ssul_video( + mp4_path: Path, user_uuid: Optional[str], content_id: int +) -> Optional[str]: + """완성 영상을 Blob 에 올리고 공개 URL 을 반환. 실패·비활성이면 None. + + Args: + mp4_path: 로컬 mp4 경로 + user_uuid: 소유자. 탈퇴로 NULL 이면 업로드하지 않는다 + content_id: `ssul_content.id` + + Returns: + SAS 토큰이 제외된 공개 URL, 또는 None + """ + if not blob_enabled(): + logger.info("[upload_ssul_video] Blob 비활성 — 로컬 서빙") + return None + if not user_uuid: + logger.warning(f"[upload_ssul_video] user_uuid 없음 id={content_id}") + return None + if not mp4_path.exists(): + logger.error(f"[upload_ssul_video] 파일 없음 {mp4_path}") + return None + + # ADO2 영상과 경로를 분리한다 + task_id = f"{ssulbox_settings.SSULBOX_BLOB_PREFIX}-{content_id}" + uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id) + + success = await uploader.upload_video(file_path=str(mp4_path)) + if not success: + logger.error(f"[upload_ssul_video] 업로드 실패 id={content_id}") + return None + + logger.info(f"[upload_ssul_video] OK id={content_id} url={uploader.public_url}") + return uploader.public_url diff --git a/app/ssulbox/services/place_service.py b/app/ssulbox/services/place_service.py new file mode 100644 index 0000000..36dfb96 --- /dev/null +++ b/app/ssulbox/services/place_service.py @@ -0,0 +1,327 @@ +# -*- coding: utf-8 -*- +"""네이버 지도 업장 검색 (키 불필요, Playwright). + +`map.naver.com/p/search/{query}` 의 검색 리스트 iframe(`pcmap.place.naver.com/place/list`) +안에 있는 `__APOLLO_STATE__` 를 파싱해, 동명 업장들을 주소로 구분한 후보 목록을 만든다. +각 후보는 `place_url`(map.naver.com/p/entry/place/{id})을 가지므로, 사용자가 하나를 고르면 +그 URL 을 그대로 생성 파이프라인(generator/naver.py)에 넘겨 정확히 그 가게를 크롤링한다. + +**castad `/search/accommodation` 으로 대체할 수 없다.** 그쪽은 네이버 *검색 API* 라 +`title`/`address`/`roadAddress` 만 주고 `place_url` 이 없는데, generator 가 place 페이지를 +크롤링하므로 URL 이 반드시 필요하다. (castad `NvMapPwScraper` 는 후보=NAVER API, +place_id 해석=Playwright 로 2단계를 밟지만 여기서는 지도만으로 한 번에 얻는다.) +""" +import asyncio +import json +import sys +from urllib.parse import quote + +from playwright.async_api import async_playwright + +from app.utils.logger import get_logger +# URL 정규화(단축링크 해석·place_id 추출)는 castad 크롤러 것을 그대로 쓴다. +# 무거운 scrap() 은 쓰지 않는다 — fetch_place_detail docstring 참조. +from app.utils.nvMapScraper import NvMapScraper, URLNotFoundException + +logger = get_logger("ssulbox") + +DESKTOP_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" +) + + +def _extract_apollo_state(html: str) -> dict | None: + """HTML 안의 `__APOLLO_STATE__ = { ... };` 객체를 중괄호 균형으로 안전하게 추출.""" + i = html.find("__APOLLO_STATE__") + if i < 0: + return None + j = html.find("{", i) + if j < 0: + return None + depth = 0 + in_str = False + esc = False + for k in range(j, len(html)): + c = html[k] + if in_str: + if esc: + esc = False + elif c == "\\": + esc = True + elif c == '"': + in_str = False + else: + if c == '"': + in_str = True + elif c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + try: + return json.loads(html[j : k + 1]) + except Exception: + return None + return None + + +def _candidates_from_state(state: dict, limit: int) -> list[dict]: + out: list[dict] = [] + for key, obj in state.items(): + if not key.startswith("PlaceListBusinessesItem:"): + continue + if not isinstance(obj, dict): + continue + name = obj.get("name") + pid = obj.get("id") + if not (name and pid and str(pid).isdigit()): + continue + out.append( + { + "title": name, + "category": obj.get("category") or "", + # 표시용 주소는 '서울 성동구 금호동3가' 같은 commonAddress 를 우선(동명 구분에 최적) + "address": obj.get("commonAddress") or obj.get("fullAddress") or "", + "roadAddress": obj.get("fullAddress") or obj.get("roadAddress") or "", + "place_url": f"https://map.naver.com/p/entry/place/{pid}", + } + ) + if len(out) >= limit: + break + return out + + +async def _search(query: str, limit: int) -> list[dict]: + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + args=["--disable-blink-features=AutomationControlled", "--no-sandbox"], + ) + try: + ctx = await browser.new_context( + user_agent=DESKTOP_UA, locale="ko-KR", timezone_id="Asia/Seoul", + viewport={"width": 1280, "height": 800}, + extra_http_headers={"Accept-Language": "ko-KR,ko;q=0.9"}, + ) + page = await ctx.new_page() + await page.goto( + f"https://map.naver.com/p/search/{quote(query)}", + wait_until="domcontentloaded", timeout=40000, + ) + # 검색 리스트 iframe 이 뜰 때까지 대기(최대 ~12초) + frame = None + for _ in range(24): + for f in page.frames: + if "pcmap.place.naver.com/place/list" in f.url: + frame = f + break + if frame: + break + # 단일 결과면 곧바로 place 상세로 리다이렉트됨 → 후보 1개로 처리. + # 이때도 **주소를 반드시 채운다**. 예전에는 빈 문자열을 돌려줬는데, + # 그러면 프론트가 주소를 못 보내고 지역(region)이 NULL 이 되어 + # 그 콘텐츠가 통합 목록의 지역 필터에서 영구 제외된다. + if "/place/" in page.url and "/search/" not in page.url: + detail = await _extract_detail_from_page(page) + if detail: + detail["place_url"] = page.url + return [detail] + # 상세 파싱까지 실패하면 최소 정보라도 준다(생성은 가능해야 한다) + return [{"title": query, "category": "", "address": "", + "roadAddress": "", "place_url": page.url}] + await page.wait_for_timeout(500) + if not frame: + return [] + # apollo state 가 채워질 시간을 조금 더 준다 + html = await frame.content() + state = _extract_apollo_state(html) + for _ in range(6): + if state and any(k.startswith("PlaceListBusinessesItem:") for k in state): + break + await page.wait_for_timeout(600) + html = await frame.content() + state = _extract_apollo_state(html) + if not state: + return [] + return _candidates_from_state(state, limit) + finally: + await browser.close() + + +def _detail_from_state(state: dict) -> dict | None: + """place 상세 페이지 apollo state → {title, category, address, roadAddress}. + + 실측(2026-07-29, `zzz/_probe_place_detail.py`): 상세는 `pcmap.place.naver.com` + iframe 안에 `PlaceDetailBase:{place_id}` 키로 들어 있고 name/address/roadAddress/ + category 를 모두 갖는다. iframe 경로에 업종 세그먼트가 끼므로 + (`/restaurant/{id}/home`) 경로를 고정하면 안 된다. + """ + for key, obj in state.items(): + if not key.startswith("PlaceDetailBase:") or not isinstance(obj, dict): + continue + name = (obj.get("name") or "").strip() + if not name: + continue + return { + "title": name, + "category": (obj.get("category") or "").strip(), + "address": (obj.get("address") or "").strip(), + "roadAddress": (obj.get("roadAddress") or "").strip(), + } + return None + + +async def _extract_detail_from_page(page, tries: int = 12) -> dict | None: + """열려 있는 place 상세 페이지에서 업장 정보를 뽑는다(iframe 탐색 + 재시도).""" + for _ in range(tries): + for frame in [page, *[f for f in page.frames if "pcmap" in f.url]]: + try: + html = await frame.content() + except Exception: + continue + state = _extract_apollo_state(html) + if not state: + continue + detail = _detail_from_state(state) + if detail: + return detail + await page.wait_for_timeout(700) + return None + + +async def _detail(place_url: str) -> dict | None: + """place URL 하나를 열어 업장명·주소를 수집.""" + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + args=["--disable-blink-features=AutomationControlled", "--no-sandbox"], + ) + try: + ctx = await browser.new_context( + user_agent=DESKTOP_UA, locale="ko-KR", timezone_id="Asia/Seoul", + viewport={"width": 1280, "height": 800}, + extra_http_headers={"Accept-Language": "ko-KR,ko;q=0.9"}, + ) + page = await ctx.new_page() + await page.goto(place_url, wait_until="domcontentloaded", timeout=40000) + detail = await _extract_detail_from_page(page) + if detail: + detail["place_url"] = page.url + return detail + finally: + await browser.close() + + +def _detail_blocking(place_url: str) -> dict | None: + """`_search_blocking` 과 같은 이유로 스레드에서 자체 루프를 쓴다.""" + loop = ( + asyncio.ProactorEventLoop() if sys.platform == "win32" + else asyncio.new_event_loop() + ) + try: + return loop.run_until_complete(_detail(place_url)) + finally: + loop.close() + + +async def fetch_place_detail( + place_url: str, timeout: float = 45.0 +) -> dict | None: + """place URL 로 업장명·주소를 수집. 실패하면 None. + + **업장명과 주소는 항상 함께 수집한다** — 주소가 없으면 지역(region)을 못 만들고, + 그러면 통합 콘텐츠 목록의 지역 필터에서 그 콘텐츠가 영구히 제외된다. + + URL 정규화는 castad `NvMapScraper.parse_url()` 을 **재사용**한다. + `naver.me` 단축링크를 브라우저 없이 HTTP 리다이렉트로 풀고 + `place.naver.com/{업종}/{id}` 형식도 처리하므로, ADO2 크롤링과 **같은 URL 형식**을 + 받아들이게 된다. 형식이 아예 아니면 브라우저를 띄우기 전에 즉시 포기한다. + + 반면 `NvMapScraper.scrap()` 은 쓰지 않는다 — 사진 다중 페이지·리뷰 통계· + 편의시설·메뉴까지 전부 긁어오므로 이름·주소만 필요한 여기에는 과하다. + + 실패해도 예외를 올리지 않는다: 이 정보는 목록 표시·필터용 부가 정보이고, + 생성 자체는 place_url 만으로 진행되므로 크롤링 실패가 생성을 막아선 안 된다. + """ + place_url = (place_url or "").strip() + if not place_url: + return None + + # 단축링크 해석 + place_id 추출 (브라우저 없이). 실패해도 원본 URL 로 계속 간다. + try: + place_id = await NvMapScraper(place_url).parse_url() + place_url = f"https://map.naver.com/p/entry/place/{place_id}" + except URLNotFoundException: + logger.warning(f"[fetch_place_detail] place URL 아님 - {place_url}") + return None + except Exception as e: + logger.info( + f"[fetch_place_detail] URL 정규화 실패, 원본으로 진행 - " + f"{type(e).__name__}: {e}" + ) + + try: + detail = await asyncio.wait_for( + asyncio.to_thread(_detail_blocking, place_url), timeout=timeout + ) + if detail: + logger.info( + f"[fetch_place_detail] {place_url} → " + f"title={detail['title']!r} road={detail['roadAddress']!r}" + ) + else: + logger.warning(f"[fetch_place_detail] 정보 없음 - {place_url}") + return detail + except asyncio.TimeoutError: + logger.warning(f"[fetch_place_detail] TIMEOUT ({timeout}s) - {place_url}") + return None + except Exception as e: + logger.error( + f"[fetch_place_detail] FAILED {place_url} - {type(e).__name__}: {e}", + exc_info=True, + ) + return None + + +def _search_blocking(query: str, limit: int) -> list[dict]: + """별도 스레드에서 자체 이벤트 루프로 Playwright 실행. + + Windows 의 웹서버 루프(Selector)는 서브프로세스를 못 띄워 Playwright 가 즉시 실패한다. + → 이 함수는 워커 스레드에서 Proactor 루프(윈도우) 를 새로 만들어 그 위에서 돌린다. + """ + if sys.platform == "win32": + loop = asyncio.ProactorEventLoop() + else: + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(_search(query, limit)) + finally: + loop.close() + + +async def search_places(query: str, limit: int = 8, timeout: float = 35.0) -> list[dict]: + """업장명으로 네이버 지도 후보 목록을 반환. 실패/타임아웃이면 빈 리스트. + + 실패해도 예외를 올리지 않는다 — 사용자에게는 네이버 링크를 직접 붙여넣는 + 우회 경로가 있으므로 흐름을 막지 않는다. 다만 원본은 예외를 통째로 삼켜 + 디버깅이 불가능했으므로 로그는 남긴다. + """ + query = (query or "").strip() + if len(query) < 2: + return [] + try: + results = await asyncio.wait_for( + asyncio.to_thread(_search_blocking, query, limit), timeout=timeout + ) + logger.info(f"[search_places] query='{query}' → {len(results)}건") + return results + except asyncio.TimeoutError: + logger.warning(f"[search_places] TIMEOUT ({timeout}s) query='{query}'") + return [] + except Exception as e: + logger.error( + f"[search_places] FAILED query='{query}' - {type(e).__name__}: {e}", + exc_info=True, + ) + return [] diff --git a/app/ssulbox/services/task_service.py b/app/ssulbox/services/task_service.py new file mode 100644 index 0000000..3199dfb --- /dev/null +++ b/app/ssulbox/services/task_service.py @@ -0,0 +1,294 @@ +# -*- coding: utf-8 -*- +"""썰박스 생성 잡 라이프사이클 — 생성(선차감) · 진행 · 완료 · 실패(환불) · 고아 스윕. + +**모든 함수는 자체 commit 하지 않는다.** caller 가 트랜잭션을 소유하고 마지막에 한 번 +커밋한다(둘 다 성공 or 둘 다 롤백). 되돌릴 수 없는 부수효과(로컬 파일 삭제)는 +`finalize_task` 가 반환한 폴더를 caller 가 **커밋 성공 후에** 정리한다. + +원본과 달라진 점: Task/Content 를 한 테이블(`ssul_content`)로 합쳤으므로 +`finalize` 가 INSERT 가 아니라 **UPDATE** 다 — 멱등성이 자연히 확보된다. +""" + +import shutil +from pathlib import Path +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.credit.services.credit_service import ( + deduct_credit_for_job, + refund_credit_for_job, +) +from app.ssulbox.constants import JOB_TYPE_SSUL, ORPHAN_STATUSES, SsulTaskStatus +from app.ssulbox.models import SsulContent +# castad 와 **같은 규칙으로** 지역을 뽑는다. 통합 목록에서 한 필터가 양쪽을 +# 걸러야 하므로 region 값의 형식이 일치해야 한다. +from app.utils.address_parser import extract_region_from_address +from app.utils.logger import get_logger +from config import ssulbox_settings + +logger = get_logger("ssulbox") + + +def _job_ref(content_id: int) -> str: + """크레딧 원장 멱등 키. (job_type, job_ref, type) 유니크의 일부""" + return str(content_id) + + +async def create_task( + session: AsyncSession, + *, + user_uuid: str, + scenario: str, + input_text: str, + scenes: int, + seconds: int, + store_name: Optional[str] = None, + road_address: Optional[str] = None, + address: Optional[str] = None, +) -> SsulContent: + """행 삽입 + 크레딧 선차감을 **한 트랜잭션**으로 묶는다. + + 사후차감이면 차감 전에 동시 요청이 들어와 크레딧 1개로 여러 개를 만들 수 있다. + 잔액 부족 시 `InsufficientCreditError` 가 전파되므로 caller 가 402 로 변환한다. + + `store_name`/`address` 는 검색으로 업장을 고른 경우에만 들어온다. + **생성 시점에 넣는 이유**: 통합 목록의 store_name/region 필터가 이 값에 걸리고, + 생성 중인 항목도 `내 콘텐츠`에서 업장명으로 보여야 한다. 완료를 기다리면 + 그 사이 목록에 이름 없는 카드가 뜬다. + 주소는 region 추출에만 쓰고 저장하지 않는다(castad `project` 와 달리 상세 주소를 + 보관할 화면이 없다). castad `/home/crawl` 과 같이 **도로명·지번을 모두** 넘겨 + 도로명에서 시/군 추출이 실패하면 지번으로 재시도하게 한다. + """ + row = SsulContent( + user_uuid=user_uuid, + scenario=scenario, + input=input_text, + scenes=scenes, + seconds=seconds, + status=SsulTaskStatus.QUEUED.value, + step=0, + # store_name 은 NOT NULL 이다. 아직 모르면 빈 문자열 — + # 채우는 쪽(`set_place_info`)이 falsy 검사로 "미확정"을 판단한다. + store_name=store_name or "", + region=extract_region_from_address(road_address or None, address or None) + or None, + # castad `/home/crawl` 과 동일: 도로명 우선, 없으면 지번 + detail_region_info=(road_address or address or None), + ) + session.add(row) + await session.flush() # autoincrement id 확보 — 크레딧 멱등 키로 쓴다 + + await deduct_credit_for_job( + session=session, + user_uuid=user_uuid, + amount=ssulbox_settings.SSULBOX_CREDITS_PER_VIDEO, + job_type=JOB_TYPE_SSUL, + job_ref=_job_ref(row.id), + reason="썰박스 생성", + ) + logger.info( + f"[create_task] id={row.id} user={user_uuid} scenario={scenario}" + ) + return row + + +async def mark_running(session: AsyncSession, content_id: int) -> None: + """잡이 실제로 시작됐을 때""" + row = await session.get(SsulContent, content_id) + if row is None or row.status != SsulTaskStatus.QUEUED.value: + return + row.status = SsulTaskStatus.RUNNING.value + + +async def update_step(session: AsyncSession, content_id: int, step: int) -> None: + """진행 단계 갱신. + + 원본은 step 을 인메모리에만 뒀지만 castad 는 SSE 대신 폴링을 쓰므로 + **DB 에 영속**해야 `GET /ssul/tasks/{id}` 가 진행률을 돌려줄 수 있다. + """ + row = await session.get(SsulContent, content_id) + if row is None or row.status in ( + SsulTaskStatus.DONE.value, + SsulTaskStatus.ERROR.value, + ): + return + row.status = SsulTaskStatus.RUNNING.value + row.step = max(row.step, step) # 뒤로 가지 않는다 + + +async def finalize_task( + session: AsyncSession, + content_id: int, + mp4_path: Path, + *, + store_name: Optional[str] = None, + region: Optional[str] = None, +) -> tuple[Optional[SsulContent], Optional[Path]]: + """완료 처리. 같은 행을 UPDATE 하므로 **멱등**이다. + + Returns: + (행, 커밋 성공 후 정리할 로컬 job 폴더 또는 None) + 폴더 삭제는 되돌릴 수 없으므로 반드시 커밋이 성공한 뒤에 한다. + """ + row = await session.get(SsulContent, content_id) + if row is None: + logger.warning(f"[finalize_task] 행 없음 id={content_id}") + return None, None + if row.status == SsulTaskStatus.DONE.value: + return row, None # 이미 처리됨 + + job_dir = mp4_path.parent + cleanup: Optional[Path] = None + video_url: Optional[str] = None + + # Blob 이 설정돼 있으면 업로드하고 로컬은 커밋 후 정리한다. + # 아니면 로컬 경로를 그대로 서빙한다. + try: + from app.ssulbox.services.blob_service import upload_ssul_video + + video_url = await upload_ssul_video(mp4_path, row.user_uuid, content_id) + if video_url: + cleanup = job_dir + except Exception as e: + logger.error( + f"[finalize_task] Blob 업로드 실패 id={content_id} - {type(e).__name__}: {e}", + exc_info=True, + ) + + if not video_url: + # 로컬 서빙 경로 (StaticFiles 마운트 기준 상대 경로) + try: + rel = mp4_path.resolve().relative_to( + ssulbox_settings.output_path.resolve() + ) + video_url = f"/ssul-videos/{rel.as_posix()}" + except ValueError: + logger.error(f"[finalize_task] output 밖 경로 id={content_id} path={mp4_path}") + + row.video_url = video_url + # 생성 시점에 이미 채워진 값(검색으로 사용자가 직접 고른 업장)이 우선이다. + # 여기 들어오는 값은 생성 로그에서 뒤늦게 주워온 것이므로 덮어쓰지 않는다. + if store_name and not row.store_name: + row.store_name = store_name + if region and not row.region: + row.region = region + row.status = SsulTaskStatus.DONE.value + row.step = 4 + logger.info(f"[finalize_task] DONE id={content_id} url={video_url}") + return row, cleanup + + +async def fail_task( + session: AsyncSession, content_id: int, error: str +) -> Optional[SsulContent]: + """실패 처리: 환불(멱등) + status=error. 이미 터미널이면 건너뛴다.""" + row = await session.get(SsulContent, content_id) + if row is None: + return None + if row.status in (SsulTaskStatus.DONE.value, SsulTaskStatus.ERROR.value): + return row + + if row.user_uuid: # 탈퇴로 NULL 이 된 경우 환불 대상이 없다 + await refund_credit_for_job( + session=session, + user_uuid=row.user_uuid, + amount=ssulbox_settings.SSULBOX_CREDITS_PER_VIDEO, + job_type=JOB_TYPE_SSUL, + job_ref=_job_ref(content_id), + reason="썰박스 생성 실패 환불", + ) + + row.status = SsulTaskStatus.ERROR.value + row.error = (error or "생성 실패")[:2000] + logger.info(f"[fail_task] id={content_id} error={row.error[:80]}") + return row + + +async def get_place_info( + session: AsyncSession, content_id: int +) -> tuple[Optional[str], Optional[str], Optional[str]]: + """(store_name, region, detail_region_info). + + 크롤링으로 채울 값이 남았는지 판단하는 데 쓴다. + """ + row = await session.get(SsulContent, content_id) + if row is None: + return None, None, None + return row.store_name, row.region, row.detail_region_info + + +async def set_place_info( + session: AsyncSession, + content_id: int, + *, + store_name: Optional[str] = None, + region: Optional[str] = None, + detail_region_info: Optional[str] = None, +) -> Optional[SsulContent]: + """크롤링으로 얻은 업장 정보를 채운다. **이미 있는 값은 덮지 않는다.** + + 사용자가 검색으로 직접 고른 값이 크롤링 추정치보다 정확하므로 우선한다. + """ + row = await session.get(SsulContent, content_id) + if row is None: + return None + if store_name and not row.store_name: + row.store_name = store_name + if region and not row.region: + row.region = region + if detail_region_info and not row.detail_region_info: + row.detail_region_info = detail_region_info + logger.info( + f"[set_place_info] id={content_id} store={row.store_name!r} " + f"region={row.region!r} detail={(row.detail_region_info or '')[:30]!r}" + ) + return row + + +async def sweep_orphans(session: AsyncSession) -> int: + """기동 시 고아 잡 정리 — 환불 + error. + + 불변식: 프로세스 기동 직후 인메모리 잡은 0개이므로 DB 의 queued/running 은 + **전부 이전 프로세스의 고아**다. finalize 가 단일 트랜잭션이라 + "영상은 만들어졌는데 running" 같은 중간 상태는 존재하지 않는다. + + ⚠️ 이 불변식은 **단일 워커 전제**다. `--workers` 를 늘리면 워커 B 가 기동하며 + 워커 A 가 지금 돌리는 잡을 고아로 오판해 환불·error 처리한다. + """ + rows = ( + ( + await session.execute( + select(SsulContent).where(SsulContent.status.in_(ORPHAN_STATUSES)) + ) + ) + .scalars() + .all() + ) + for row in rows: + if row.user_uuid: + await refund_credit_for_job( + session=session, + user_uuid=row.user_uuid, + amount=ssulbox_settings.SSULBOX_CREDITS_PER_VIDEO, + job_type=JOB_TYPE_SSUL, + job_ref=_job_ref(row.id), + reason="서버 재시작 환불", + ) + row.status = SsulTaskStatus.ERROR.value + row.error = "서버 재시작으로 중단되었습니다. 크레딧은 환불되었습니다." + if rows: + logger.info(f"[sweep_orphans] 고아 {len(rows)}건 환불·정리") + return len(rows) + + +def cleanup_job_dir(job_dir: Optional[Path]) -> None: + """생성 산출물 폴더 삭제. **커밋이 성공한 뒤에만** 호출할 것.""" + if not job_dir or not job_dir.exists(): + return + try: + shutil.rmtree(job_dir) + logger.info(f"[cleanup_job_dir] 삭제 {job_dir}") + except Exception as e: + logger.warning(f"[cleanup_job_dir] 삭제 실패 {job_dir} - {e}") diff --git a/app/ssulbox/worker/__init__.py b/app/ssulbox/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ssulbox/worker/job_manager.py b/app/ssulbox/worker/job_manager.py new file mode 100644 index 0000000..36d6947 --- /dev/null +++ b/app/ssulbox/worker/job_manager.py @@ -0,0 +1,461 @@ +# -*- coding: utf-8 -*- +"""썰박스 생성 잡 매니저 — subprocess 감독 + 진행 단계 DB 영속화. + +원본(o2o-ssulbox/app/jobs.py)에서 이식하되 4가지를 바꿨다: + +1. **SSE 제거** — castad 는 폴링을 쓴다. `_subscribers`/`_emit` 을 통째로 걷어냈다. +2. **step 을 DB 에 영속화** — 원본은 인메모리 `_jobs` 에만 뒀지만, 폴링이 진행률을 + 돌려주려면 DB 에 있어야 한다. 나중에 워커를 늘려도 폴링은 그대로 동작한다. +3. **세션 팩토리를 BackgroundSessionLocal 로** — 요청용 풀(20+20)을 장시간 잡이 + 잠식하지 않게 한다. castad `video_task.py` 와 같은 관행. +4. **좀비 방지** — 원본은 `p.wait()` 에 타임아웃이 없어 엔진이 걸리면 스레드가 + 영구 블록되고 `_running` 이 안 줄어 큐가 멎었다. **감시견 타이머**로 데드라인에 + 프로세스를 죽인다. `wait(timeout=)` 만 걸면 안 된다 — stdout 읽기 루프가 + EOF 까지 블록하므로 엔진이 조용히 매달리면 wait() 에 도달조차 못 한다 + (2026-07-29 `zzz/_ssul_timeout_verify.py` 로 확인한 실제 결함). + +⚠️ **단일 워커 전제** — `_jobs`/`_running` 이 프로세스 로컬이고, `sweep_orphans` 가 +"기동 시 비터미널 잡은 전부 고아"라는 불변식에 의존한다. `--workers` 를 늘리면 +워커 B 가 기동하며 워커 A 의 정상 잡을 환불·error 처리한다. +""" + +import asyncio +import os +import subprocess +import sys +import threading +import time +from collections import deque +from pathlib import Path +from typing import Any, Optional + +from app.database.session import BackgroundSessionLocal +from app.ssulbox.constants import ( + DONE_RE, + JOB_DIR_RE, + SCENARIO_ENGINE, + STEP_NAMES, + STEP_RE, + STORE_RE, + gemini_key, +) +from app.ssulbox.services import place_service, task_service +from app.utils.address_parser import extract_region_from_address +from app.utils.logger import get_logger +from config import ssulbox_settings + +logger = get_logger("ssulbox") + +# ── 프로세스 로컬 상태 ──────────────────────────────────────── +#: 진행 중인 잡의 로그·타이밍 (권위 아님 — 권위는 DB status/step) +_jobs: dict[int, dict[str, Any]] = {} +_queue: deque[int] = deque() +_lock = threading.Lock() +_running = 0 +_shutting_down = False +#: 앱 이벤트 루프. 워커 스레드가 DB 작업을 위임할 대상 +_loop: Optional[asyncio.AbstractEventLoop] = None + + +def create_job( + content_id: int, scenario: str, input_text: str, scenes: int, seconds: int +) -> None: + """잡을 큐에 넣는다. + + **반드시 앱 이벤트 루프(요청 핸들러)에서 호출해야 한다** — 여기서 루프를 캡처해 + 워커 스레드가 DB 작업을 위임할 때 쓴다. + """ + global _loop + _loop = asyncio.get_running_loop() + _jobs[content_id] = { + "id": content_id, + "scenario": scenario, + "input": input_text, + "scenes": scenes, + "seconds": seconds, + "status": "queued", + "step": 0, + "log": [], + "output": None, + "job_dir": None, # generator 가 stdout 으로 알려준다. 실패 정리 대상 + "store_name": None, # 검색을 안 거친 경우 생성 로그에서 주워온다 + "error": None, + "timings": {}, + } + with _lock: + _queue.append(content_id) + _pump() + + +def get_job(content_id: int) -> Optional[dict]: + """인메모리 진행 정보. 로그 확인용이며 권위는 DB 다.""" + return _jobs.get(content_id) + + +def shutdown() -> None: + """신규 큐잉을 막는다. lifespan shutdown 에서 dispose_engine 전에 호출.""" + global _shutting_down + _shutting_down = True + with _lock: + dropped = len(_queue) + _queue.clear() + if dropped: + logger.info(f"[job_manager] 종료 — 대기 중이던 {dropped}건은 다음 기동 스윕이 처리") + + +def _run_db(coro, timeout: Optional[int] = None): + """워커 스레드에서 앱 루프에 DB 코루틴을 위임하고 완료까지 대기한다. + + asyncmy 커넥션 풀은 생성된 이벤트 루프에 바인딩되므로 스레드에서 + `asyncio.run` 을 쓰면 풀이 깨진다. 반드시 앱 루프에 위임해야 한다. + """ + if _loop is None or _loop.is_closed(): + # 셧다운 중이면 루프가 코루틴을 실행하지 않아 무한정 매달린다. + coro.close() + raise RuntimeError("event loop unavailable (shutting down)") + fut = asyncio.run_coroutine_threadsafe(coro, _loop) + return fut.result(timeout=timeout or ssulbox_settings.SSULBOX_DB_DELEGATE_TIMEOUT) + + +async def _mark_running(content_id: int) -> None: + async with BackgroundSessionLocal() as session: + await task_service.mark_running(session, content_id) + await session.commit() + + +async def _update_step(content_id: int, step: int) -> None: + async with BackgroundSessionLocal() as session: + await task_service.update_step(session, content_id, step) + await session.commit() + + +async def _get_place_info( + content_id: int, +) -> tuple[Optional[str], Optional[str], Optional[str]]: + """현재 저장된 (store_name, region, detail_region_info).""" + async with BackgroundSessionLocal() as session: + return await task_service.get_place_info(session, content_id) + + +async def _save_place( + content_id: int, store_name: str, region: str, detail: str +) -> None: + async with BackgroundSessionLocal() as session: + await task_service.set_place_info( + session, + content_id, + store_name=store_name, + region=region, + detail_region_info=detail, + ) + await session.commit() + + +async def _finalize( + content_id: int, mp4: Path, store_name: Optional[str] = None +) -> None: + cleanup: Optional[Path] = None + async with BackgroundSessionLocal() as session: + try: + # store_name 은 생성 시점에 비어 있을 때만 반영된다(finalize_task 가 판단). + _, cleanup = await task_service.finalize_task( + session, content_id, mp4, store_name=store_name + ) + await session.commit() + except Exception: + await session.rollback() + raise + # 되돌릴 수 없는 삭제는 커밋이 성공한 뒤에만 + task_service.cleanup_job_dir(cleanup) + + +async def _fail(content_id: int, error: str) -> None: + async with BackgroundSessionLocal() as session: + try: + await task_service.fail_task(session, content_id, error) + await session.commit() + except Exception: + await session.rollback() + raise + + +def _pump() -> None: + """동시 실행 한도 안에서 대기 중인 잡을 시작한다.""" + global _running + if _shutting_down: + return + with _lock: + while _queue and _running < ssulbox_settings.SSULBOX_MAX_CONCURRENT_JOBS: + content_id = _queue.popleft() + _running += 1 + threading.Thread(target=_run, args=(content_id,), daemon=True).start() + + +def _build_command(job: dict) -> tuple[list[str], Path]: + """generator 실행 커맨드와 작업 디렉터리.""" + engine = SCENARIO_ENGINE[job["scenario"]] + engine_dir = ssulbox_settings.generator_path / engine + main_py = engine_dir / "main.py" + if not main_py.exists(): + raise FileNotFoundError(f"generator not found: {main_py}") + + cmd = [ + sys.executable, + "-u", + str(main_py), + job["input"], + "--scenes", + str(job["scenes"]), + "--seconds", + str(job["seconds"]), + ] + return cmd, engine_dir + + +def _is_place_url(text: str) -> bool: + """네이버 지도 링크로 보이는가 — 프론트 `isNaverUrl` 과 같은 판정.""" + t = (text or "").strip().lower() + return t.startswith("http") and ( + "naver.me" in t or "map.naver" in t or "place.naver" in t + ) + + +def _collect_place_info(content_id: int, job: dict) -> None: + """비어 있는 업장명·지역을 크롤링으로 채운다(있는 값은 유지). + + **빠진 값이 있을 때만 크롤링한다.** 검색으로 고른 경우 create 시점에 둘 다 + 채워져 있으므로 브라우저를 띄우지 않는다. 다만 검색 경로에서도 지역만 비는 + 경우(주소에서 시/군을 못 뽑는 등)가 있어, 존재 여부를 실제로 확인하고 판단한다. + + 수집 실패는 삼킨다. 이 정보가 없어도 생성은 place_url 만으로 진행된다. + """ + if not _is_place_url(job.get("input", "")): + return + try: + store_name, region, detail = _run_db(_get_place_info(content_id)) + if store_name and region and detail: + return # 채울 것이 없다 + + detail = _run_db( + place_service.fetch_place_detail(job["input"]), + # Playwright 기동 + 상세 파싱까지 DB 위임 기본 타임아웃(60s)보다 길 수 있다 + timeout=120, + ) + if not detail: + return + + title = detail.get("title") or "" + road = detail.get("roadAddress") or "" + jibun = detail.get("address") or "" + # castad `/home/crawl` 과 동일하게 **도로명·지번을 모두** 넘긴다. + # 도로명에서 시/군 추출이 실패하면 지번으로 재시도한다(한쪽만 넘기면 놓친다). + new_region = extract_region_from_address(road or None, jibun or None) + new_detail = road or jibun # 도로명 우선, 없으면 지번 + if title: + job["store_name"] = title + if title or new_region or new_detail: + _run_db(_save_place(content_id, title, new_region, new_detail)) + except Exception as e: + logger.warning( + f"[ssul {content_id}] 업장 정보 수집 실패(생성은 계속): " + f"{type(e).__name__}: {e}" + ) + + +def _run(content_id: int) -> None: + """워커 스레드 본체. subprocess 를 감독하며 stdout 마커로 진행을 파싱한다.""" + global _running + job = _jobs[content_id] + proc: Optional[subprocess.Popen] = None + + try: + cmd, engine_dir = _build_command(job) + + env = dict(os.environ) + env["PYTHONIOENCODING"] = "utf-8" + key = gemini_key() + if key: + env["GEMINI_API_KEY"] = key + + _run_db(_mark_running(content_id)) + job["status"] = "running" + + # 업장명·주소를 **항상** 확보한다. 검색으로 고르지 않고 place URL 을 + # 붙여넣은 경우 create 시점에 아무것도 없으므로 여기서 크롤링한다. + # 생성 **전에** 하는 이유: 목록에 이름 없는 카드가 뜨는 구간을 없앤다. + # 실패해도 생성은 계속한다(목록 표시·필터용 부가 정보다). + _collect_place_info(content_id, job) + + proc = subprocess.Popen( + cmd, + cwd=str(engine_dir), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + + started = step_started = time.monotonic() + timeout_s = ssulbox_settings.SSULBOX_JOB_TIMEOUT_SECONDS + + # ⚠️ 벽시계 감시견이 필요한 이유 — `proc.wait(timeout=)` 만으로는 못 막는다. + # 아래 `for raw in proc.stdout` 은 EOF 까지 블록한다. 엔진이 출력 없이 + # 매달리면(응답 없는 API 호출 등) EOF 가 오지 않아 워커 스레드가 영구 + # 정지하고 `_running` 이 안 줄어 **큐 전체가 멎는다**. wait() 의 타임아웃은 + # stdout 이 닫힌 뒤에야 평가되므로 정작 그 상황에는 도달하지 못한다. + # 데드라인에 프로세스를 죽여야 stdout 이 닫히고 루프가 풀린다. + timed_out = threading.Event() + + def _on_deadline() -> None: + timed_out.set() + logger.error(f"[ssul {content_id}] TIMEOUT {timeout_s}s — 프로세스 종료") + _kill(proc) + + watchdog = threading.Timer(timeout_s, _on_deadline) + watchdog.daemon = True + watchdog.start() + + try: + for raw in proc.stdout: # type: ignore[union-attr] + line = raw.rstrip() + if not line: + continue + logger.info(f"[ssul {content_id}] {line}") + job["log"].append(line) + if len(job["log"]) > 300: + del job["log"][:-300] + + m = STEP_RE.search(line) + if m: + n = int(m.group(1)) + if n != job["step"]: + now = time.monotonic() + prev = job["step"] + label = "준비·크롤링" if prev == 0 else STEP_NAMES[prev - 1] + job["timings"][label] = round(now - step_started, 1) + step_started = now + job["step"] = n + # 폴링이 읽을 수 있도록 DB 에 반영한다 + try: + _run_db(_update_step(content_id, n)) + except Exception as e: + logger.warning(f"[ssul {content_id}] step 저장 실패: {e}") + + d = DONE_RE.search(line) + if d: + job["output"] = d.group(1).strip() + + # 실패 시 지울 대상. 완료 마커가 없어도 알 수 있는 유일한 경로다. + jd = JOB_DIR_RE.search(line) + if jd: + job["job_dir"] = jd.group(1).strip() + + # place URL 을 붙여넣어 검색을 안 거친 경우의 업장명 확보 경로. + # '?' 는 엔진이 이름을 못 얻었을 때 찍는 placeholder 라 버린다. + sm = STORE_RE.search(line) + if sm: + name = sm.group(1).strip() + if name and name != "?": + job["store_name"] = name + + # stdout 이 닫혔으니 종료는 임박했다. 짧은 여유만 준다. + code = proc.wait(timeout=30) + finally: + watchdog.cancel() + + if timed_out.is_set(): + # 감시견이 죽인 것이지 정상 종료가 아니다. 아래 except 로 넘긴다. + raise subprocess.TimeoutExpired(cmd, timeout_s) + + summary = " · ".join(f"{k} {v}s" for k, v in job["timings"].items()) or "(마커 없음)" + logger.info( + f"[ssul {content_id}] 단계별 {summary} · 총 {time.monotonic() - started:.1f}s" + ) + + if code != 0: + raise RuntimeError(f"생성 프로세스 종료코드 {code}") + if not job["output"]: + raise RuntimeError("완성 마커를 찾지 못했습니다") + + mp4 = Path(job["output"]) + if not mp4.is_absolute(): + mp4 = (engine_dir / mp4).resolve() + + # finalize 는 Blob 업로드를 포함해 오래 걸리므로 위임 타임아웃을 넉넉히 + _run_db( + _finalize(content_id, mp4, job.get("store_name")), timeout=600 + ) + job["status"] = "done" + job["step"] = 4 + + except subprocess.TimeoutExpired as e: + # 원본은 타임아웃이 없어 엔진이 걸리면 큐가 영구 정지했다. + # 두 경로로 들어온다: 감시견 데드라인, 또는 stdout 이 닫혔는데도 + # 프로세스가 안 끝나는 경우(위 `wait(timeout=30)`). + job["status"] = "error" + job["error"] = f"생성 시간 초과 ({e.timeout}s)" + logger.error(f"[ssul {content_id}] TIMEOUT — 프로세스 종료") + _kill(proc) + _safe_fail(content_id, job["error"]) + + except Exception as e: + job["status"] = "error" + job["error"] = f"{type(e).__name__}: {e}" + logger.error(f"[ssul {content_id}] FAILED - {job['error']}", exc_info=True) + _kill(proc) + _safe_fail(content_id, job["error"]) + + finally: + with _lock: + _running -= 1 + _pump() + + +def _kill(proc: Optional[subprocess.Popen]) -> None: + if proc is None or proc.poll() is not None: + return + try: + proc.kill() + except Exception as e: + logger.warning(f"[job_manager] 프로세스 종료 실패: {e}") + + +def _safe_fail(content_id: int, error: str) -> None: + """환불·상태 갱신. 이것마저 실패하면 다음 기동의 고아 스윕이 재처리한다.""" + try: + _run_db(_fail(content_id, error)) + except Exception as e: + logger.error( + f"[ssul {content_id}] 실패 처리 실패(스윕이 재처리): {type(e).__name__}: {e}" + ) + finally: + # DB 처리 성패와 무관하게 디스크는 정리한다. 남겨봐야 쓸 곳이 없다. + _cleanup_failed_dir(content_id) + + +def _cleanup_failed_dir(content_id: int) -> None: + """실패한 잡의 중간 산출물 삭제. + + castad `video_task.py` 는 `finally` 로 성공·실패 양쪽을 정리한다. 썰박스도 + 맞춘다. 실패 시엔 generator 자신의 자산 정리(`main.py` 의 `out_mp4.exists()` + 조건)마저 건너뛰므로 **오히려 실패가 가장 많이 남긴다** — 이미지·음성 전체. + """ + raw = (_jobs.get(content_id) or {}).get("job_dir") + if not raw: + # 폴더 마커 전에 죽었다면 만들어진 것도 없다 + return + try: + job_dir = Path(raw).resolve() + root = ssulbox_settings.output_path.resolve() + # stdout 에서 읽어온 경로다. output 밖은 무슨 일이 있어도 지우지 않는다. + # `job_dir.parent != root` 는 `output/<엔진>` 통째 삭제를 막는다 + # (정상 경로는 항상 `output/<엔진>/<작업>` 이라 2단계 아래다). + if root not in job_dir.parents or job_dir.parent == root: + logger.warning(f"[ssul {content_id}] output 밖 경로라 정리 생략: {job_dir}") + return + task_service.cleanup_job_dir(job_dir) + except Exception as e: + logger.warning(f"[ssul {content_id}] 실패 정리 실패: {type(e).__name__}: {e}") diff --git a/app/user/models.py b/app/user/models.py index c9e1a1b..392d18a 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -143,6 +143,12 @@ class User(Base): comment="카카오 썸네일 이미지 URL", ) + bio: Mapped[Optional[str]] = mapped_column( + String(200), + nullable=True, + comment="한 줄 소개 (썰박스 프로필). Alembic 부재로 수동 DDL 필요 — app/ssulbox/migration.py 참조", + ) + # ========================================================================== # 추가 사용자 정보 # ========================================================================== diff --git a/app/user/services/credit.py b/app/user/services/credit.py index 90dbc74..c18bfc4 100644 --- a/app/user/services/credit.py +++ b/app/user/services/credit.py @@ -10,7 +10,15 @@ logger = logging.getLogger(__name__) async def consume_credit(user_uuid: str, session: AsyncSession, *, reason: str = "video generation") -> bool: - """크레딧 1 차감. 기존 호출처와 시그니처 호환 유지.""" + """크레딧 1 차감. 기존 호출처와 시그니처 호환 유지. + + .. deprecated:: + 사전차감 정책 전환으로 호출처가 사라졌다(video_task.py 의 사후차감 제거). + **새 코드에서 쓰지 말 것.** 이 함수는 멱등성이 없어 재시도 시 중복 차감된다. + 생성 작업에는 ``credit_service.deduct_credit_for_job`` / + ``refund_credit_for_job`` 을 쓴다. + 다음 릴리스에서 제거 예정. + """ try: await deduct_credit( session=session, diff --git a/app/video/api/routers/internal/reactions.py b/app/video/api/routers/internal/reactions.py index a8808e5..803645f 100644 --- a/app/video/api/routers/internal/reactions.py +++ b/app/video/api/routers/internal/reactions.py @@ -12,11 +12,15 @@ from sqlalchemy import delete, insert, tuple_ from sqlalchemy.ext.asyncio import AsyncSession from app.database.like_cache import ( + CT_SSUL, + CT_VIDEO, commit_dirty_processing, drain_dirty, is_user_liked, ) from app.database.session import get_session +# 썰박스 좋아요도 같은 Redis write-behind 큐 + **같은 테이블**(video_reaction)을 +# 쓰므로 여기서 함께 플러시한다. 큐가 하나라 스케줄러를 늘릴 필요가 없다. from app.video.models import VideoReaction from config import internal_settings @@ -48,49 +52,68 @@ async def flush_reactions( detail="Invalid internal secret", ) - pairs = await drain_dirty() - if not pairs: + entries = await drain_dirty() + if not entries: logger.info("[REACTION_FLUSH] dirty 항목 없음, 종료") return {"flushed": 0, "adds": 0, "dels": 0} - logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(pairs)}건") + logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(entries)}건") + # 두 종류가 **같은 테이블**(video_reaction)에 들어간다. 대상 컬럼만 다르다. + # ADO2 는 video_id, 썰박스는 content_id 를 채운다(나머지는 NULL, CHECK 로 강제). + id_col_of = {CT_VIDEO: "video_id", CT_SSUL: "content_id"} adds: list[dict] = [] - dels: list[tuple[int, str]] = [] + # (컬럼명, 대상 id, user_uuid) — 삭제는 컬럼이 달라 종류별로 묶어야 한다 + dels: dict[str, list[tuple[int, str]]] = {"video_id": [], "content_id": []} # Redis 현재 상태 기준으로 add / delete 분류 - for video_id, user_uuid in pairs: - liked = await is_user_liked(video_id, user_uuid) + for ctype, content_id, user_uuid in entries: + id_col = id_col_of.get(ctype) + if id_col is None: + # 알 수 없는 종류 — 건너뛴다(파서가 걸러주지만 방어) + logger.warning(f"[REACTION_FLUSH] 알 수 없는 종류 무시 - ctype: {ctype}") + continue + liked = await is_user_liked(content_id, user_uuid, ctype=ctype) if liked: - adds.append({"video_id": video_id, "user_uuid": user_uuid}) + adds.append({id_col: content_id, "user_uuid": user_uuid}) else: - dels.append((video_id, user_uuid)) + dels[id_col].append((content_id, user_uuid)) + + total_adds = len(adds) + total_dels = sum(len(v) for v in dels.values()) try: - # Bulk INSERT IGNORE — UniqueConstraint 보장으로 멱등 처리 - if adds: - await session.execute( - insert(VideoReaction).prefix_with("IGNORE").values(adds) - ) - - # Bulk DELETE - if dels: - await session.execute( - delete(VideoReaction).where( - tuple_( - VideoReaction.video_id, - VideoReaction.user_uuid, - ).in_(dels) + # 쓰기를 **하나의 트랜잭션**으로 묶는다. + # 부분 반영이 나면 Redis processing SET 이 남아 다음 회차에 재시도되는데, + # INSERT IGNORE + UNIQUE 로 멱등이라 중복 반영은 안전하다. + # + # ⚠️ adds 는 종류별로 키가 달라(video_id vs content_id) 한 번의 + # `values(adds)` 로 묶으면 안 된다 — SQLAlchEmy 가 첫 dict 의 키로 + # 컬럼 목록을 정하므로 다른 종류의 값이 누락된다. 종류별로 나눠 실행한다. + for id_col in ("video_id", "content_id"): + rows = [a for a in adds if id_col in a] + if rows: + await session.execute( + insert(VideoReaction).prefix_with("IGNORE").values(rows) + ) + + if dels[id_col]: + await session.execute( + delete(VideoReaction).where( + tuple_( + getattr(VideoReaction, id_col), + VideoReaction.user_uuid, + ).in_(dels[id_col]) + ) ) - ) await session.commit() await commit_dirty_processing() logger.info( - f"[REACTION_FLUSH] SUCCESS - adds: {len(adds)}, dels: {len(dels)}" + f"[REACTION_FLUSH] SUCCESS - adds: {total_adds}, dels: {total_dels}" ) - return {"flushed": len(pairs), "adds": len(adds), "dels": len(dels)} + return {"flushed": len(entries), "adds": total_adds, "dels": total_dels} except Exception as e: await session.rollback() diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 9cc465b..348c440 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -13,12 +13,10 @@ Video API Router app.include_router(router) """ -import json -from collections import defaultdict from typing import Literal from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query -from sqlalchemy import func, or_, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_session @@ -28,26 +26,26 @@ from app.user.models import User from app.utils.pagination import PaginatedResponse from app.home.models import Image, Project, MarketingIntel from app.home.api.routers.v1.home import _extract_region_from_address -from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES from app.lyric.models import Lyric from app.song.models import Song, SongTimestamp from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP -from app.comment.models import Comment from app.database.like_cache import ( backfill_user_set, - bulk_is_user_liked, get_like_count, - get_like_counts, is_user_liked, is_user_set_exists, mark_dirty, - mset_like_counts, set_like_count, toggle_like_atomic, ) +from app.credit.exceptions import InsufficientCreditError +from app.credit.services.credit_service import deduct_credit_for_job +from app.ssulbox.constants import JOB_TYPE_VIDEO as CREDIT_JOB_TYPE_VIDEO from app.utils.logger import get_logger from app.video.models import Video, VideoReaction +from app.video.services import unified_list + from app.video.schemas.video_schema import ( DownloadVideoResponse, GenerateVideoResponse, @@ -64,6 +62,9 @@ from config import creatomate_settings logger = get_logger("video") +#: 영상 1편 생성에 차감할 크레딧 (video_task.py 의 VIDEO_CREDIT_COST 와 동일해야 함) +VIDEO_CREDIT_COST = 1 + router = APIRouter(prefix="/video", tags=["Video"]) @@ -113,12 +114,19 @@ curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5 - Song의 song_result_url과 song_prompt가 있어야 영상 생성이 가능합니다. - creatomate_render_id를 사용하여 /status/{creatomate_render_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다. - Video 테이블에 데이터가 저장되며, project_id, lyric_id, song_id가 자동으로 연결됩니다. + +## 크레딧 +- **요청 시점에 크레딧 1이 선차감됩니다.** (완료 시점 차감에서 변경) +- 생성이 실패하면 자동으로 환불됩니다. +- 잔액이 부족하면 402를 반환하며, Video 행도 생성되지 않습니다. +- 같은 task_id 로 재요청해도 중복 차감되지 않습니다. """, response_model=GenerateVideoResponse, responses={ 200: {"description": "영상 생성 요청 성공"}, 400: {"description": "Song의 음악 URL, 가사(song_prompt) 또는 이미지가 없음"}, 401: {"description": "인증 실패 (토큰 없음/만료)"}, + 402: {"description": "크레딧 부족 (충전 필요)"}, 404: {"description": "Project, Lyric, Song 또는 Image를 찾을 수 없음"}, 500: {"description": "영상 생성 요청 실패"}, }, @@ -313,7 +321,11 @@ async def generate_video( f"timestamps: {len(song_timestamp_list)}" ) - # ===== Video 테이블에 초기 데이터 저장 및 커밋 ===== + # ===== Video 테이블에 초기 데이터 저장 + 크레딧 선차감 (단일 트랜잭션) ===== + # 렌더 완료 후가 아니라 "시작 시점"에 차감한다. 사후차감이던 시절에는 + # 차감 전에 동시 요청이 들어오면 크레딧 1개로 영상 여러 개를 만들 수 있었다. + # Video insert 와 차감을 한 트랜잭션으로 묶어 한 번만 커밋해야 + # "영상 행은 생겼는데 차감은 실패" 같은 반쪽 상태가 생기지 않는다. video = Video( project_id=project_id, lyric_id=lyric_id, @@ -323,6 +335,21 @@ async def generate_video( status="processing", ) session.add(video) + await session.flush() # video.id 확보 (커밋은 차감 후 한 번만) + + # job_ref 로 task_id 를 쓴다 — ADO2 에는 영상 재생성 버튼이 없어 + # task_id 1건 = 생성 1건이기 때문이다. + # 재생성 UI 를 추가한다면 이 키를 f"{task_id}:{video.id}" 로 바꿔야 + # 두 번째 생성이 공짜가 되지 않는다. + await deduct_credit_for_job( + session=session, + user_uuid=current_user.user_uuid, + amount=VIDEO_CREDIT_COST, + job_type=CREDIT_JOB_TYPE_VIDEO, + job_ref=task_id, + reason="영상 생성", + ) + await session.commit() video_id = video.id stage1_time = time.perf_counter() @@ -334,6 +361,14 @@ async def generate_video( except HTTPException: raise + except InsufficientCreditError: + # 크레딧 부족은 "요청 실패"가 아니라 402 로 올려야 프론트가 충전 화면으로 유도한다. + # 아래 except Exception 이 삼켜 200(success=False)으로 내리면 안 되므로 먼저 잡는다. + logger.info( + f"[generate_video] INSUFFICIENT CREDIT - task_id: {task_id}, " + f"user_uuid: {current_user.user_uuid}" + ) + raise except Exception as e: logger.error(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}") return GenerateVideoResponse( @@ -850,7 +885,7 @@ async def get_all_videos( store_name: str | None = Query(default=None, description="업체명 검색 (부분 일치)"), region: str | None = Query(default=None, description="지역명 검색 (부분 일치)"), ) -> PaginatedResponse[VideoThumbnailItem]: - """전체 사용자의 완료된 영상 갤러리를 반환합니다.""" + """전체 사용자의 완료된 콘텐츠(ADO2 영상 + 썰박스)를 반환합니다.""" logger.info( f"[get_all_videos] START - page: {pagination.page}, page_size: {pagination.page_size}, " f"sort_by: {sort_by}, order: {order}, store_name: {store_name}, region: {region}" @@ -859,144 +894,31 @@ async def get_all_videos( try: offset = (pagination.page - 1) * pagination.page_size - where_clauses = [ - Video.status == "completed", - Video.is_deleted == False, # noqa: E712 - Project.is_deleted == False, # noqa: E712 - Video.result_movie_url.is_not(None), - ] - if store_name: - where_clauses.append(Project.store_name.ilike(f"%{store_name}%")) - if region: - cities = SIDO_CITIES.get(region) - if cities: - aliases = SIDO_SEARCH_ALIASES.get(region, [region]) - where_clauses.append( - or_( - Project.region.in_(cities), - *[Project.detail_region_info.ilike(f"%{a}%") for a in aliases], - ) - ) - else: - where_clauses.append( - or_( - Project.region.ilike(f"%{region}%"), - Project.detail_region_info.ilike(f"%{region}%"), - ) - ) - - count_q = ( - select(func.count(Video.id)) - .join(Project, Video.project_id == Project.id) - .where(*where_clauses) + items, total = await unified_list.fetch_gallery( + session, + offset=offset, + limit=pagination.page_size, + sort_by=sort_by, + order=order, + store_name=store_name, + region=region, + user_uuid=current_user.user_uuid if current_user else None, ) - total = (await session.execute(count_q)).scalar() or 0 - - comment_count_subq = ( - select(func.count(Comment.id)) - .where( - Comment.video_id == Video.id, - Comment.is_deleted == False, # noqa: E712 - ) - .correlate(Video) - .scalar_subquery() - ) - - # like_count 정렬은 Redis 대신 서브쿼리로 처리 (ORDER BY에만 사용) - like_count_subq_for_sort = ( - select(func.count(VideoReaction.id)) - .where(VideoReaction.video_id == Video.id) - .correlate(Video) - .scalar_subquery() - ) - sort_col_map = { - "like_count": like_count_subq_for_sort, - "comment_count": comment_count_subq, - "created_at": Video.created_at, - } - sort_col = sort_col_map.get(sort_by, Video.created_at) - order_clause = sort_col.asc() if order == "asc" else sort_col.desc() - - list_q = ( - select( - Video, - Project, - comment_count_subq.label("comment_count"), - ) - .join(Project, Video.project_id == Project.id) - .where(*where_clauses) - .order_by(order_clause) - .offset(offset) - .limit(pagination.page_size) - ) - rows = (await session.execute(list_q)).all() - - video_ids = [v.id for v, p, _ in rows] - - # Redis mget으로 like_count 일괄 조회 - like_count_map = await get_like_counts(video_ids) - - # 카운트 캐시 미스 보정 - missing_ids = [vid for vid, cnt in like_count_map.items() if cnt is None] - if missing_ids: - db_counts = (await session.execute( - select(VideoReaction.video_id, func.count(VideoReaction.id)) - .where(VideoReaction.video_id.in_(missing_ids)) - .group_by(VideoReaction.video_id) - )).all() - db_found_ids = set() - batch = {} - for vid, cnt in db_counts: - batch[vid] = cnt - like_count_map[vid] = cnt - db_found_ids.add(vid) - await mset_like_counts(batch) - for vid in missing_ids: - if vid not in db_found_ids: - like_count_map[vid] = 0 - - # is_liked_by_me: Redis user-set 기준, cold-start 시 DB backfill - liked_map: dict[int, bool] = {} - if current_user: - raw_liked = await bulk_is_user_liked(video_ids, current_user.user_uuid) - - # user-set이 없는(None) 영상 중 count > 0인 것만 backfill 필요 - needs_backfill = [ - vid for vid, liked in raw_liked.items() - if liked is None and like_count_map.get(vid, 0) > 0 - ] - if needs_backfill: - reaction_rows = (await session.execute( - select(VideoReaction.video_id, VideoReaction.user_uuid) - .where(VideoReaction.video_id.in_(needs_backfill)) - )).all() - user_map: dict[int, list[str]] = defaultdict(list) - for vid, uuid in reaction_rows: - user_map[vid].append(uuid) - for vid in needs_backfill: - await backfill_user_set(vid, user_map.get(vid, [])) - - # backfill 후 재조회 - updated = await bulk_is_user_liked(needs_backfill, current_user.user_uuid) - raw_liked.update(updated) - - liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()} - - items = [ - VideoThumbnailItem( - video_id=v.id, - store_name=p.store_name, - result_movie_url=v.result_movie_url, - created_at=v.created_at, - like_count=like_count_map.get(v.id) or 0, - is_liked_by_me=liked_map.get(v.id, False), - comment_count=comment_count or 0, - ) - for v, p, comment_count in rows - ] response = PaginatedResponse.create( - items=items, + items=[ + VideoThumbnailItem( + type=it.ctype, + video_id=it.id, + store_name=it.store_name, + result_movie_url=it.movie_url, + created_at=it.created_at, + like_count=it.like_count, + is_liked_by_me=it.is_liked_by_me, + comment_count=it.comment_count, + ) + for it in items + ], total=total, page=pagination.page, page_size=pagination.page_size, @@ -1009,6 +931,7 @@ async def get_all_videos( raise HTTPException(status_code=500, detail=f"갤러리 조회에 실패했습니다: {str(e)}") + @router.post( "/{video_id}/like", summary="영상 좋아요 토글", diff --git a/app/video/models.py b/app/video/models.py index 888fc7b..8fe853c 100644 --- a/app/video/models.py +++ b/app/video/models.py @@ -1,7 +1,18 @@ from datetime import datetime from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Index, + Integer, + String, + UniqueConstraint, + func, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base @@ -136,8 +147,11 @@ class Video(Base): back_populates="videos", ) + # comment/video_reaction 이 video_id 와 content_id 두 FK 를 갖게 되어 + # 어느 쪽으로 조인할지 명시해야 한다(없으면 AmbiguousForeignKeysError). comments: Mapped[List["Comment"]] = relationship( "Comment", + foreign_keys="Comment.video_id", back_populates="video", cascade="all, delete-orphan", lazy="noload", @@ -145,6 +159,7 @@ class Video(Base): reactions: Mapped[List["VideoReaction"]] = relationship( "VideoReaction", + foreign_keys="VideoReaction.video_id", back_populates="video", cascade="all, delete-orphan", lazy="noload", @@ -170,14 +185,30 @@ class VideoReaction(Base): 영상 반응 테이블 사용자가 영상에 반응(현재는 좋아요)을 남기면 생성, 다시 누르면 삭제(토글). - (user_uuid, video_id) 유니크 제약으로 1인 1회 보장. 향후 reaction_type 컬럼 추가로 다양한 반응 종류 확장 가능. + + **ADO2 영상과 썰박스 콘텐츠를 모두 담는다.** 대상은 `video_id` 또는 `content_id` + 중 **정확히 하나**만 채워지며 DB `CHECK` 로 강제한다. + + 1인 1회 보장은 유니크 두 개로 나눠서 한다. MySQL 은 NULL 을 서로 다른 값으로 + 취급하므로, 썰박스 행(video_id IS NULL)이 아무리 많아도 + `uq_video_reaction_user_video` 에 걸리지 않는다 — 각자 자기 유니크만 지킨다. """ __tablename__ = "video_reaction" __table_args__ = ( + CheckConstraint( + "(video_id IS NULL) <> (content_id IS NULL)", + name="ck_video_reaction_one_target", + ), UniqueConstraint("user_uuid", "video_id", name="uq_video_reaction_user_video"), + UniqueConstraint( + "user_uuid", "content_id", name="uq_video_reaction_user_content" + ), Index("idx_video_reaction_video_id", "video_id"), + # 카운트 집계가 content_id 로 묶으므로 선행 컬럼 인덱스가 필요하다 + # (유니크는 user_uuid 가 앞이라 이 용도로 못 쓴다). + Index("idx_video_reaction_content_id", "content_id"), Index("idx_video_reaction_user_uuid", "user_uuid"), { "mysql_engine": "InnoDB", @@ -189,11 +220,19 @@ class VideoReaction(Base): id: Mapped[int] = mapped_column( Integer, primary_key=True, autoincrement=True, comment="고유 식별자" ) - video_id: Mapped[int] = mapped_column( + # 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_video_reaction_one_target). + video_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("video.id", ondelete="CASCADE"), - nullable=False, - comment="연결된 Video의 id", + nullable=True, + comment="ADO2 영상 id (썰박스 반응이면 NULL)", + ) + content_id: Mapped[Optional[int]] = mapped_column( + # ssul_content.id 는 BIGINT 다. INT 로 두면 FK 타입 불일치(errno 3780). + BigInteger, + ForeignKey("ssul_content.id", ondelete="CASCADE"), + nullable=True, + comment="썰박스 콘텐츠 id (ADO2 반응이면 NULL)", ) user_uuid: Mapped[str] = mapped_column( String(36), @@ -208,5 +247,8 @@ class VideoReaction(Base): comment="반응 일시", ) - video: Mapped["Video"] = relationship("Video", back_populates="reactions") + # 썰박스 반응이면 None 이다. 접근하는 쪽에서 반드시 방어할 것. + video: Mapped[Optional["Video"]] = relationship( + "Video", foreign_keys=[video_id], back_populates="reactions" + ) user: Mapped["User"] = relationship("User", back_populates="video_reactions") diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index f6599cb..b9a21a9 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -5,7 +5,7 @@ Video API Schemas """ from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, Literal, Optional from pydantic import BaseModel, ConfigDict, Field @@ -152,10 +152,22 @@ class VideoListItem(BaseModel): } """ - video_id: int = Field(..., description="영상 고유 ID") + # ⚠️ `video_id` 는 type 안에서만 유일하다 — `video.id` 와 `ssul_content.id` 는 + # 각각 1부터 시작하는 독립 시퀀스다. 식별·삭제·상세 열기 모두 + # **`(type, video_id)` 쌍**으로 다뤄야 한다. + # 특히 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지우므로, + # 썰박스 항목의 id 를 그대로 넘기면 **엉뚱한 ADO2 영상이 삭제된다.** + type: Literal["video", "ssul"] = Field( + default="video", + description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스)", + ) + video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)") store_name: Optional[str] = Field(None, description="업체명") region: Optional[str] = Field(None, description="지역명") - task_id: str = Field(..., description="작업 고유 식별자") + task_id: str = Field( + default="", + description="작업 고유 식별자 (ADO2 전용. 썰박스는 개념이 없어 빈 문자열)", + ) result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") created_at: Optional[datetime] = Field(None, description="생성 일시") like_count: int = Field(0, description="좋아요 수") @@ -169,7 +181,14 @@ class VideoThumbnailItem(BaseModel): GET /video/all 응답의 개별 영상 정보 """ - video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)") + # ⚠️ `video_id` 는 종류 안에서만 유일하다. `video.id` 와 `ssul_content.id` 가 + # **둘 다 1부터 시작**하므로 식별자는 반드시 `(type, video_id)` 쌍으로 다뤄야 한다. + # 한 곳이라도 id 만 쓰면 다른 종류의 콘텐츠가 열린다. + type: Literal["video", "ssul"] = Field( + default="video", + description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스). video_id 와 쌍으로 식별한다", + ) + video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)") store_name: str = Field(..., description="업체명") result_movie_url: str = Field(..., description="영상 URL — 프론트에서