o2o-castad-backend/app/video/api/routers/internal/reactions.py

122 lines
4.8 KiB
Python

"""
내부 전용 좋아요 반응 플러시 API
스케줄러가 1분마다 호출하여 Redis dirty SET의 좋아요 토글을 MySQL에 bulk write합니다.
X-Internal-Secret 헤더로 인증합니다.
"""
import logging
from fastapi import APIRouter, Depends, Header, HTTPException, status
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/video", tags=["Internal"])
@router.post(
"/reactions/flush",
summary="[내부] 좋아요 반응 DB 플러시",
description="스케줄러 서버에서 1분마다 호출하는 내부 전용 엔드포인트입니다. "
"Redis dirty SET의 항목을 MySQL video_reaction 테이블에 bulk write합니다.",
)
async def flush_reactions(
session: AsyncSession = Depends(get_session),
x_internal_secret: str = Header(...),
) -> dict:
"""Redis dirty SET → MySQL bulk write.
1. drain_dirty(): dirty SET을 processing으로 RENAME 후 항목 조회
2. 각 항목의 현재 Redis 상태(is_liked) 확인
3. is_liked=True → INSERT IGNORE, is_liked=False → DELETE
4. commit_dirty_processing(): processing SET 삭제
"""
if x_internal_secret != internal_settings.INTERNAL_SECRET_KEY:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid internal secret",
)
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(entries)}건")
# 두 종류가 **같은 테이블**(video_reaction)에 들어간다. 대상 컬럼만 다르다.
# ADO2 는 video_id, 썰박스는 content_id 를 채운다(나머지는 NULL, CHECK 로 강제).
id_col_of = {CT_VIDEO: "video_id", CT_SSUL: "content_id"}
adds: list[dict] = []
# (컬럼명, 대상 id, user_uuid) — 삭제는 컬럼이 달라 종류별로 묶어야 한다
dels: dict[str, list[tuple[int, str]]] = {"video_id": [], "content_id": []}
# Redis 현재 상태 기준으로 add / delete 분류
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({id_col: content_id, "user_uuid": user_uuid})
else:
dels[id_col].append((content_id, user_uuid))
total_adds = len(adds)
total_dels = sum(len(v) for v in dels.values())
try:
# 쓰기를 **하나의 트랜잭션**으로 묶는다.
# 부분 반영이 나면 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: {total_adds}, dels: {total_dels}"
)
return {"flushed": len(entries), "adds": total_adds, "dels": total_dels}
except Exception as e:
await session.rollback()
logger.error(f"[REACTION_FLUSH] EXCEPTION - error: {e}")
raise HTTPException(status_code=500, detail=f"플러시 실패: {str(e)}")