49d8f68986
자료실 자료를 사용자가 명시적으로 "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 (손글씨 트랙과 분리)
113 lines
4.0 KiB
Python
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)
|