영상과 같이 업로드·SEO 결과를 행에 남기고, 다음 요청부터는 DB 값을 쓴다. Co-authored-by: Cursor <cursoragent@cursor.com>
485 lines
18 KiB
Python
485 lines
18 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, 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
|