122 lines
4.8 KiB
Python
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)}")
|