# -*- 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, null, 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.p2v.models import P2vF1Job, P2vF2Job from app.ssulbox.models import SsulContent from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES from app.video.models import Video, VideoReaction #: p2v_video(무빙 포스터 영상)·p2v_poster(스타일링 이미지). 내 콘텐츠와 갤러리 #: 양쪽에 나오지만(2026-08-26 갤러리 합류) **좋아요·댓글 축은 없다** — enrich 는 #: CT_VIDEO/CT_SSUL 만 돌아 0 으로 남고, 프론트도 P2V 카드에는 소셜 액션을 숨긴다. #: 갤러리의 F2 는 퍼블릭 도메인 템플릿 결과물만 나간다(fetch_gallery 참조). ContentType = Literal["video", "ssul", "p2v_video", "p2v_poster"] CT_P2V_VIDEO = "p2v_video" CT_P2V_POSTER = "p2v_poster" #: 정렬 가능한 키. 그 외 값은 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 _p2v_f1_branch(where: list, sort_by: str = SORT_CREATED) -> Select: """무빙 포스터(F1) 브랜치. 통합 목록의 축과 P2V 필드 대응: store_name←행사명, region←장소, hashtags←키워드. P2V 에는 좋아요·댓글 축이 없으므로 해당 정렬에서는 sort_value 0 으로 뒤에 깔린다 (UNION 컬럼 수 맞춤용). """ cols = [ literal(CT_P2V_VIDEO).label("ctype"), P2vF1Job.id.label("cid"), func.coalesce(P2vF1Job.event_name, P2vF1Job.name).label("store_name"), P2vF1Job.place.label("region"), P2vF1Job.p2v_video_url.label("movie_url"), P2vF1Job.created_at.label("created_at"), literal("").label("task_id"), P2vF1Job.poster_url.label("poster_url"), P2vF1Job.name.label("title"), null().label("description"), P2vF1Job.keywords.label("hashtags"), ] if sort_by in (SORT_LIKE, SORT_COMMENT): cols.append(literal(0).label("sort_value")) return select(*cols).where(*where) def _p2v_f2_branch(where: list, sort_by: str = SORT_CREATED) -> Select: """포스터 스타일링(F2) 브랜치 — 결과물이 영상이 아니라 이미지다. movie_url 자리에 결과 이미지 URL 을 싣는다. **프론트는 ctype 으로 분기해 로 그려야 한다** (ssul/video 처럼