# -*- coding: utf-8 -*- """ADO2 영상 + 썰박스 콘텐츠 통합 목록. `GET /video/all`(전체 갤러리)과 `GET /archive/videos/`(내 콘텐츠)가 두 종류를 **하나의 정렬·페이징된 목록**으로 내려주기 위한 공용 쿼리다. **왜 프론트에서 못 합치나**: 정렬·검색·지역 필터·페이지네이션이 전부 서버사이드다. 두 API 를 각각 호출해 이어붙이면 1페이지에 ADO2 12개 + 썰박스 12개가 각자 안에서만 정렬된 채 섞이고 `total`/`has_next` 도 어긋난다. **왜 테이블을 안 합치나**: `video` 는 `project_id`/`lyric_id`/`song_id` 가 NOT NULL 이고 소유자·업장명이 `project` 에 있다. 썰박스를 넣으려면 그 4개를 nullable 로 열고 `Video` 를 참조하는 코드 72곳을 전부 감사해야 한다(2026-07-30 조사). 여기 사는 이유: 두 엔드포인트(`app/video`, `app/archive`)가 함께 쓰는데 엔드포인트 소유가 video 쪽이라 여기 뒀다. `app/archive` 는 이미 `app.video.models` 를 import 한다. ## 성능 설계 — 카운트를 UNION 안에 넣지 않는다 UNION 은 양쪽 브랜치를 **먼저 구체화**하므로, select 목록에 상관 서브쿼리를 두면 페이지 12건이 아니라 **매칭된 전체 행**에 대해 평가된다. 그래서 `created_at` 정렬(기본)에서는 카운트를 빼고, 페이지가 확정된 뒤 그 12건만 집계한다. 좋아요·댓글 수로 **정렬할 때만** 어쩔 수 없이 브랜치 안에서 계산한다. """ from dataclasses import dataclass from datetime import datetime from typing import Literal, Optional from sqlalchemy import Select, func, literal, or_, select, union_all from sqlalchemy.ext.asyncio import AsyncSession from app.comment.models import Comment from app.database.like_cache import ( CT_SSUL, CT_VIDEO, backfill_user_set, bulk_is_user_liked, get_like_counts, mset_like_counts, ) from app.home.models import Project from app.ssulbox.models import SsulContent from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES from app.video.models import Video, VideoReaction ContentType = Literal["video", "ssul"] #: 정렬 가능한 키. 그 외 값은 created_at 으로 떨어진다. SORT_CREATED = "created_at" SORT_LIKE = "like_count" SORT_COMMENT = "comment_count" @dataclass(slots=True) class UnifiedItem: """통합 목록의 한 항목. `id` 는 종류마다 **독립적인 시퀀스**다(`video.id` 와 `ssul_content.id` 모두 1부터 시작한다). 따라서 식별자는 반드시 `(ctype, id)` 쌍으로 다뤄야 한다 — 한 곳이라도 id 만 쓰면 다른 콘텐츠가 열린다. """ ctype: ContentType id: int store_name: str region: Optional[str] movie_url: str created_at: datetime #: ADO2 전용. 썰박스는 task_id 개념이 없어 빈 문자열이다 #: (`ssul_content` 는 task/content 를 한 테이블로 합쳤고 크레딧 앵커는 id 다). task_id: str = "" like_count: int = 0 comment_count: int = 0 is_liked_by_me: bool = False poster_url: Optional[str] = None title: Optional[str] = None description: Optional[str] = None hashtags: Optional[list] = None # ────────────────────────────────────────────── # 상관 서브쿼리 (정렬용) # ────────────────────────────────────────────── def _video_like_subq(): return ( select(func.count(VideoReaction.id)) .where(VideoReaction.video_id == Video.id) .correlate(Video) .scalar_subquery() ) def _video_comment_subq(): return ( select(func.count(Comment.id)) .where(Comment.video_id == Video.id, Comment.is_deleted.is_(False)) .correlate(Video) .scalar_subquery() ) def _ssul_like_subq(): # 좋아요·댓글은 castad 테이블에 합쳤다(2026-07-30). 썰박스 행은 video_id 대신 # content_id 가 채워져 있다 — 테이블은 같고 컬럼만 다르다. return ( select(func.count(VideoReaction.id)) .where(VideoReaction.content_id == SsulContent.id) .correlate(SsulContent) .scalar_subquery() ) def _ssul_comment_subq(): return ( select(func.count(Comment.id)) .where( Comment.content_id == SsulContent.id, Comment.is_deleted.is_(False), ) .correlate(SsulContent) .scalar_subquery() ) # ────────────────────────────────────────────── # 필터 # ────────────────────────────────────────────── def _region_clause(region_col, detail_col, region: str): """castad `/video/all` 과 **동일한 규칙**으로 지역을 거른다. 시/도 이름이면 그 안의 시·군 목록으로 매칭하고, 동시에 상세 주소를 별칭으로 부분 일치 검색한다. 이 두 경로를 맞추지 않으면 같은 검색어에 ADO2 는 나오고 썰박스는 안 나오는 비대칭이 생긴다. """ cities = SIDO_CITIES.get(region) if cities: aliases = SIDO_SEARCH_ALIASES.get(region, [region]) return or_( region_col.in_(cities), *[detail_col.ilike(f"%{a}%") for a in aliases], ) return or_( region_col.ilike(f"%{region}%"), detail_col.ilike(f"%{region}%"), ) def _video_where(store_name: Optional[str], region: Optional[str]) -> list: clauses = [ Video.status == "completed", Video.is_deleted.is_(False), Project.is_deleted.is_(False), Video.result_movie_url.is_not(None), ] if store_name: clauses.append(Project.store_name.ilike(f"%{store_name}%")) if region: clauses.append( _region_clause(Project.region, Project.detail_region_info, region) ) return clauses def _ssul_where(store_name: Optional[str], region: Optional[str]) -> list: clauses = [ SsulContent.status == "done", SsulContent.is_deleted.is_(False), SsulContent.video_url.is_not(None), ] if store_name: clauses.append(SsulContent.store_name.ilike(f"%{store_name}%")) if region: clauses.append( _region_clause( SsulContent.region, SsulContent.detail_region_info, region ) ) return clauses # ────────────────────────────────────────────── # 브랜치 # ────────────────────────────────────────────── def _video_branch(where: list, sort_by: str) -> Select: cols = [ literal(CT_VIDEO).label("ctype"), Video.id.label("cid"), Project.store_name.label("store_name"), Project.region.label("region"), Video.result_movie_url.label("movie_url"), Video.created_at.label("created_at"), Video.task_id.label("task_id"), Video.poster_url.label("poster_url"), Video.title.label("title"), Video.description.label("description"), Video.hashtags.label("hashtags"), ] if sort_by == SORT_LIKE: cols.append(_video_like_subq().label("sort_value")) elif sort_by == SORT_COMMENT: cols.append(_video_comment_subq().label("sort_value")) return select(*cols).join(Project, Video.project_id == Project.id).where(*where) def _ssul_branch(where: list, sort_by: str) -> Select: cols = [ literal(CT_SSUL).label("ctype"), SsulContent.id.label("cid"), SsulContent.store_name.label("store_name"), SsulContent.region.label("region"), SsulContent.video_url.label("movie_url"), SsulContent.created_at.label("created_at"), # UNION 은 컬럼 수·순서가 양쪽 같아야 한다. 썰박스에는 task_id 가 없다. literal("").label("task_id"), SsulContent.poster_url.label("poster_url"), SsulContent.title.label("title"), SsulContent.description.label("description"), SsulContent.hashtags.label("hashtags"), ] if sort_by == SORT_LIKE: cols.append(_ssul_like_subq().label("sort_value")) elif sort_by == SORT_COMMENT: cols.append(_ssul_comment_subq().label("sort_value")) return select(*cols).where(*where) # ────────────────────────────────────────────── # 페이지 확정 후 집계 # ────────────────────────────────────────────── def _reaction_target(ctype: str): """반응 테이블에서 이 종류가 쓰는 대상 컬럼. `comment` / `video_reaction` 은 두 종류를 함께 담고 `video_id` 또는 `content_id` 중 하나만 채운다(CHECK 로 강제). 어느 컬럼을 볼지만 갈아끼우면 된다. """ return VideoReaction.video_id if ctype == CT_VIDEO else VideoReaction.content_id def _comment_target(ctype: str): return Comment.video_id if ctype == CT_VIDEO else Comment.content_id async def _like_counts_for( session: AsyncSession, ctype: str, ids: list[int] ) -> dict[int, int]: """Redis 우선, 미스는 DB 로 보정하고 캐시에 채운다. castad 기존 목록 로직과 동일한 절차다. 종류별 키 접두를 쓰므로 `video.id` 와 `ssul_content.id` 가 겹쳐도 섞이지 않는다. """ if not ids: return {} counts = await get_like_counts(ids, ctype=ctype) missing = [cid for cid, cnt in counts.items() if cnt is None] if missing: col = _reaction_target(ctype) stmt = ( select(col, func.count(VideoReaction.id)) .where(col.in_(missing)) .group_by(col) ) found = {cid: cnt for cid, cnt in (await session.execute(stmt)).all()} await mset_like_counts(found, ctype=ctype) for cid in missing: counts[cid] = found.get(cid, 0) return {cid: (cnt or 0) for cid, cnt in counts.items()} async def _comment_counts_for( session: AsyncSession, ctype: str, ids: list[int] ) -> dict[int, int]: """페이지에 포함된 항목만 집계한다(UNION 안에서 계산하지 않는 이유는 모듈 docstring 참조).""" if not ids: return {} col = _comment_target(ctype) stmt = ( select(col, func.count(Comment.id)) .where(col.in_(ids), Comment.is_deleted.is_(False)) .group_by(col) ) found = {cid: cnt for cid, cnt in (await session.execute(stmt)).all()} return {cid: found.get(cid, 0) for cid in ids} async def _liked_map_for( session: AsyncSession, ctype: str, ids: list[int], user_uuid: str, like_counts: dict[int, int], ) -> dict[int, bool]: """Redis user-set 기준. cold-start 인 항목만 DB 에서 backfill 한다.""" if not ids: return {} raw = await bulk_is_user_liked(ids, user_uuid, ctype=ctype) # user-set 키가 없고(None) 카운트가 0 보다 큰 것만 채우면 된다 — # 좋아요가 0 이면 backfill 해도 결과가 같다. needs = [cid for cid, liked in raw.items() if liked is None and like_counts.get(cid, 0) > 0] if needs: col = _reaction_target(ctype) stmt = select(col, VideoReaction.user_uuid).where(col.in_(needs)) by_content: dict[int, list[str]] = {cid: [] for cid in needs} for cid, uuid in (await session.execute(stmt)).all(): by_content[cid].append(uuid) for cid in needs: await backfill_user_set(cid, by_content[cid], ctype=ctype) raw.update(await bulk_is_user_liked(needs, user_uuid, ctype=ctype)) return {cid: bool(liked) for cid, liked in raw.items()} async def enrich( session: AsyncSession, items: list[UnifiedItem], user_uuid: Optional[str], ) -> list[UnifiedItem]: """페이지에 실린 항목에만 좋아요·댓글 수와 내 좋아요 여부를 채운다.""" for ctype in (CT_VIDEO, CT_SSUL): ids = [it.id for it in items if it.ctype == ctype] if not ids: continue likes = await _like_counts_for(session, ctype, ids) comments = await _comment_counts_for(session, ctype, ids) liked = ( await _liked_map_for(session, ctype, ids, user_uuid, likes) if user_uuid else {} ) for it in items: if it.ctype != ctype: continue it.like_count = likes.get(it.id, 0) it.comment_count = comments.get(it.id, 0) it.is_liked_by_me = liked.get(it.id, False) return items # ────────────────────────────────────────────── # 공개 API # ────────────────────────────────────────────── async def fetch_gallery( session: AsyncSession, *, offset: int, limit: int, sort_by: str = SORT_CREATED, order: str = "desc", store_name: Optional[str] = None, region: Optional[str] = None, user_uuid: Optional[str] = None, include_ssul: bool = True, ) -> tuple[list[UnifiedItem], int]: """전체 갤러리(`/video/all`). (항목, 전체 개수) 반환.""" v_where = _video_where(store_name, region) s_where = _ssul_where(store_name, region) # 전체 개수는 브랜치별로 센다 — UNION 을 만들어 세는 것보다 싸다. total = ( await session.execute( select(func.count(Video.id)) .join(Project, Video.project_id == Project.id) .where(*v_where) ) ).scalar() or 0 if include_ssul: total += ( await session.execute( select(func.count(SsulContent.id)).where(*s_where) ) ).scalar() or 0 branches = [_video_branch(v_where, sort_by)] if include_ssul: branches.append(_ssul_branch(s_where, sort_by)) u = union_all(*branches).subquery() if len(branches) > 1 else branches[0].subquery() sort_col = ( u.c.sort_value if sort_by in (SORT_LIKE, SORT_COMMENT) else u.c.created_at ) order_by = [sort_col.asc() if order == "asc" else sort_col.desc()] # 정렬 키가 같을 때 페이지 경계에서 순서가 흔들리면 같은 항목이 두 번 보이거나 # 아예 빠진다. 안정적인 2차 키를 반드시 둔다. if sort_by in (SORT_LIKE, SORT_COMMENT): order_by.append(u.c.created_at.desc()) order_by.extend([u.c.ctype.asc(), u.c.cid.desc()]) rows = ( await session.execute( select(u).order_by(*order_by).offset(offset).limit(limit) ) ).all() items = _to_items(rows) await enrich(session, items, user_uuid) return items, total def _to_items(rows) -> list[UnifiedItem]: return [ UnifiedItem( ctype=r.ctype, id=r.cid, store_name=r.store_name or "", region=r.region, movie_url=r.movie_url, created_at=r.created_at, task_id=r.task_id or "", poster_url=r.poster_url, title=r.title, description=r.description, hashtags=list(r.hashtags) if r.hashtags else None, ) for r in rows ] async def fetch_my_contents( session: AsyncSession, *, user_uuid: str, offset: int, limit: int, ) -> tuple[list[UnifiedItem], int]: """내 콘텐츠(`/archive/videos/`). (항목, 전체 개수) 반환. 갤러리와 다른 점 둘: - 소유자로 거른다 (ADO2 는 `project.user_uuid`, 썰박스는 `ssul_content.user_uuid`) - ADO2 는 **task_id 당 최신 1건만** 남긴다(같은 작업으로 여러 영상이 생길 수 있다). 썰박스는 task_id 개념이 없어 중복 제거가 필요 없다. ⚠️ **프론트는 반드시 `ctype` 으로 분기해야 한다.** 이 목록에는 삭제 버튼이 붙는데 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지운다. 썰박스 항목의 id 를 그대로 넘기면 **id 가 겹치는 ADO2 영상이 삭제된다**(둘 다 1부터 시작하는 독립 시퀀스). """ # ADO2: task_id 별 최신 영상만 (기존 동작 보존) latest_ids = ( select(func.max(Video.id).label("latest_id")) .join(Project, Video.project_id == Project.id) .where( Project.user_uuid == user_uuid, Video.status == "completed", Video.is_deleted.is_(False), Project.is_deleted.is_(False), ) .group_by(Video.task_id) .subquery() ) v_where = [Video.id.in_(select(latest_ids.c.latest_id))] s_where = [ SsulContent.user_uuid == user_uuid, SsulContent.status == "done", SsulContent.is_deleted.is_(False), SsulContent.video_url.is_not(None), ] total = ((await session.execute( select(func.count(Video.id)).where(*v_where) )).scalar() or 0) + ((await session.execute( select(func.count(SsulContent.id)).where(*s_where) )).scalar() or 0) u = union_all( _video_branch(v_where, SORT_CREATED), _ssul_branch(s_where, SORT_CREATED), ).subquery() rows = ( await session.execute( select(u) .order_by(u.c.created_at.desc(), u.c.ctype.asc(), u.c.cid.desc()) .offset(offset) .limit(limit) ) ).all() items = _to_items(rows) await enrich(session, items, user_uuid) return items, total