"""자료실 회독 카운트 API — append-only 로그 기반. 동작 규칙 (사용자 명시): - detail 페이지 진입만으로 자동 +1 금지. 명시 클릭 시에만 호출. - POST /api/documents/{id}/read → row 1개 insert (회독 +1) - GET /api/documents/{id}/read-stats → {read_count, last_read_at} - DELETE /api/documents/{id}/read/last → 현재 사용자의 그 문서 마지막 row 1개만 삭제 ownership: - documents 테이블에 user_id 없음 (single-user). document_reads.user_id 로 사용자 분리. multi-user 전환 시 documents.user_id 추가 후 ownership check 필요. """ import logging from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from core.database import get_session from models.document import Document from models.document_read import DocumentRead from models.user import User logger = logging.getLogger(__name__) router = APIRouter() class ReadStats(BaseModel): read_count: int last_read_at: datetime | None async def _get_stats( session: AsyncSession, user_id: int, document_id: int ) -> ReadStats: row = await session.execute( select( func.count(DocumentRead.id), func.max(DocumentRead.read_at), ).where( DocumentRead.user_id == user_id, DocumentRead.document_id == document_id, ) ) count, last = row.one() return ReadStats(read_count=int(count or 0), last_read_at=last) async def _verify_document_visible( session: AsyncSession, document_id: int ) -> Document: """문서 존재 + 미삭제 확인. ownership 은 single-user 가정으로 통과.""" doc = await session.get(Document, document_id) if doc is None or getattr(doc, "deleted_at", None) is not None: raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") return doc @router.post("/{document_id}/read", response_model=ReadStats, status_code=201) async def add_read( document_id: int, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """회독 +1 — 사용자 명시 클릭. 같은 날 여러 번 호출 가능 (각각 별개 회독).""" await _verify_document_visible(session, document_id) session.add(DocumentRead(user_id=user.id, document_id=document_id)) await session.commit() return await _get_stats(session, user.id, document_id) @router.get("/{document_id}/read-stats", response_model=ReadStats) async def get_read_stats( document_id: int, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """현재 사용자의 그 문서 회독 통계.""" await _verify_document_visible(session, document_id) return await _get_stats(session, user.id, document_id) @router.delete("/{document_id}/read/last", response_model=ReadStats) async def delete_last_read( document_id: int, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """현재 사용자의 그 문서 마지막 회독 row 1개만 삭제 (실수 클릭 취소).""" await _verify_document_visible(session, document_id) # 현재 사용자 + 해당 문서의 가장 최근 row 1건만. last = await session.execute( select(DocumentRead.id) .where( DocumentRead.user_id == user.id, DocumentRead.document_id == document_id, ) .order_by(DocumentRead.read_at.desc(), DocumentRead.id.desc()) .limit(1) ) last_id = last.scalar_one_or_none() if last_id is not None: await session.execute( delete(DocumentRead).where(DocumentRead.id == last_id) ) await session.commit() return await _get_stats(session, user.id, document_id)