o2o-castad-backend/app/video/services/unified_list.py
2026-08-26 13:52:18 +09:00

607 lines
23 KiB
Python

# -*- 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 으로 분기해
<img> 로 그려야 한다** (ssul/video 처럼 <video> 에 넣으면 안 나온다).
"""
cols = [
literal(CT_P2V_POSTER).label("ctype"),
P2vF2Job.id.label("cid"),
# 카드 제목 = 포스터 이름(업로드 파일명). 이름이 없던 구버전 행만 템플릿명으로.
func.coalesce(P2vF2Job.name, P2vF2Job.template_name, literal("포스터 스타일링")).label("store_name"),
null().label("region"),
P2vF2Job.p2v_poster_url.label("movie_url"),
P2vF2Job.created_at.label("created_at"),
literal("").label("task_id"),
# 썸네일 = 결과물 자신 (별도 썸네일이 없다)
P2vF2Job.p2v_poster_url.label("poster_url"),
# 스타일 정보는 제목이 아니라 부가 정보 자리(title)에 남긴다
P2vF2Job.template_name.label("title"),
null().label("description"),
null().label("hashtags"),
]
if sort_by in (SORT_LIKE, SORT_COMMENT):
cols.append(literal(0).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)
# ── P2V (2026-08-26 갤러리 합류) ──────────────────────────────
# Blob 아카이브까지 끝난 완성본만. 검색어는 행사명/포스터명에 대응시킨다.
f1_where = [
P2vF1Job.status == "done",
P2vF1Job.archived_at.is_not(None),
P2vF1Job.p2v_video_url.is_not(None),
]
if store_name:
f1_where.append(
func.coalesce(P2vF1Job.event_name, P2vF1Job.name).ilike(f"%{store_name}%")
)
if region:
# place 는 자유 텍스트 장소라 시/도 별칭 매칭(_region_clause)이 안 맞는다 —
# 부분 일치로만 거른다.
f1_where.append(P2vF1Job.place.ilike(f"%{region}%"))
# F2 공개 조건 셋: ① 설정 플래그(P2V_POSTER_IN_GALLERY — 임시 비노출 중),
# ② 퍼블릭 도메인 템플릿으로 만든 것만(사용자 업로드 레퍼런스는 권리 미확인이라
# 공개 금지 — 내 콘텐츠에는 나온다), ③ 지역 개념이 없어 지역 필터가 걸리면 빠진다.
from config import p2v_settings
include_f2 = p2v_settings.P2V_POSTER_IN_GALLERY and region is None
f2_where = [
P2vF2Job.status == "done",
P2vF2Job.archived_at.is_not(None),
P2vF2Job.p2v_poster_url.is_not(None),
P2vF2Job.license == "public-domain",
]
if store_name:
f2_where.append(
func.coalesce(P2vF2Job.name, P2vF2Job.template_name).ilike(f"%{store_name}%")
)
# 전체 개수는 브랜치별로 센다 — 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
total += (
await session.execute(select(func.count(P2vF1Job.id)).where(*f1_where))
).scalar() or 0
if include_f2:
total += (
await session.execute(select(func.count(P2vF2Job.id)).where(*f2_where))
).scalar() or 0
branches = [_video_branch(v_where, sort_by)]
if include_ssul:
branches.append(_ssul_branch(s_where, sort_by))
branches.append(_p2v_f1_branch(f1_where, sort_by))
if include_f2:
branches.append(_p2v_f2_branch(f2_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),
]
# P2V — Blob 아카이브까지 끝난(URL 이 실재하는) 완성본만.
# archiving 중이거나 실패한 잡은 P2V 진행 화면이 담당하므로 여기 안 나온다.
f1_where = [
P2vF1Job.user_uuid == user_uuid,
P2vF1Job.status == "done",
P2vF1Job.archived_at.is_not(None),
P2vF1Job.p2v_video_url.is_not(None),
]
f2_where = [
P2vF2Job.user_uuid == user_uuid,
P2vF2Job.status == "done",
P2vF2Job.archived_at.is_not(None),
P2vF2Job.p2v_poster_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) + ((await session.execute(
select(func.count(P2vF1Job.id)).where(*f1_where)
)).scalar() or 0) + ((await session.execute(
select(func.count(P2vF2Job.id)).where(*f2_where)
)).scalar() or 0)
u = union_all(
_video_branch(v_where, SORT_CREATED),
_ssul_branch(s_where, SORT_CREATED),
_p2v_f1_branch(f1_where),
_p2v_f2_branch(f2_where),
).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