Files
hyungi_document_server/app/api/document_reads.py
T
Hyungi Ahn 49d8f68986 feat(library): 자료실 회독 카운트 추적 (PR-A backend)
자료실 자료를 사용자가 명시적으로 "1회독 완료" 클릭 시 +1 누적.
detail 진입 자동 카운트 . append-only 로그.

데이터:
- migrations 174~176: document_reads 테이블 + 인덱스 2개 (단일 statement 분할)

ORM:
- app/models/document_read.py: DocumentRead (user_id, document_id, read_at)

API (app/api/document_reads.py, /api/documents prefix):
- POST   /api/documents/{id}/read       — 회독 +1
- GET    /api/documents/{id}/read-stats — {read_count, last_read_at}
- DELETE /api/documents/{id}/read/last  — 현재 사용자의 그 문서 마지막 1건만
  · ownership: WHERE user_id=current_user.id AND document_id=:doc_id
  · documents 에 user_id 부재 (single-user). multi-user 전환 시 ownership
    check 추가 필요 — 코드 주석 명시.

응답 확장:
- DocumentResponse: read_count(default 0), last_read_at(default None)
- /api/documents/library: 페이지 N건 한정 LEFT JOIN 으로 read 통계 매핑 (N+1 회피)
- /api/library/tree CategoryTreeNode: unread_count 추가
  · 기존 path_docs 가 ancestor 누적 구조라 그대로 활용 — 하위 경로 합산 자동

규칙 (사용자 명시 — 변경 금지):
  · 같은 날 여러 번 클릭 → 각각 별개 회독
  · 실수 클릭 취소 = DELETE /read/last
  · documents 에 read_count 컬럼 추가 , 로그 기반 COUNT(*) 만

plan: ~/.claude/plans/scalable-chasing-stonebraker.md
브랜치: feature/library-reads (손글씨 트랙과 분리)
2026-04-27 12:08:36 +09:00

113 lines
4.0 KiB
Python

"""자료실 회독 카운트 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)