""" 내부 전용 좋아요 반응 플러시 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)}")