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 (손글씨 트랙과 분리)
This commit is contained in:
Hyungi Ahn
2026-04-27 12:08:36 +09:00
parent 33d4fd39c4
commit 49d8f68986
8 changed files with 229 additions and 2 deletions
+112
View File
@@ -0,0 +1,112 @@
"""자료실 회독 카운트 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)
+31 -1
View File
@@ -109,6 +109,9 @@ class DocumentResponse(BaseModel):
embedded_at: datetime | None
created_at: datetime
updated_at: datetime
# 회독 추적 (자료실 등) — 현재 사용자 기준. 다른 endpoint 응답에선 0/None.
read_count: int = 0
last_read_at: datetime | None = None
class Config:
from_attributes = True
@@ -333,8 +336,35 @@ async def list_library_documents(
result = await session.execute(query)
items = result.scalars().all()
# 회독 통계 한 번에 fetch (현재 페이지 N건 한정 — N+1 회피)
from models.document_read import DocumentRead
read_map: dict[int, tuple[int, datetime | None]] = {}
if items:
doc_ids = [d.id for d in items]
rs = await session.execute(
select(
DocumentRead.document_id,
func.count(DocumentRead.id),
func.max(DocumentRead.read_at),
)
.where(
DocumentRead.user_id == user.id,
DocumentRead.document_id.in_(doc_ids),
)
.group_by(DocumentRead.document_id)
)
for did, cnt, last in rs:
read_map[did] = (int(cnt or 0), last)
def _to_resp(doc):
resp = DocumentResponse.model_validate(doc)
cnt, last = read_map.get(doc.id, (0, None))
resp.read_count = cnt
resp.last_read_at = last
return resp
return DocumentListResponse(
items=[DocumentResponse.model_validate(doc) for doc in items],
items=[_to_resp(doc) for doc in items],
total=total,
page=page,
page_size=page_size,
+18 -1
View File
@@ -51,6 +51,9 @@ class CategoryTreeNode(BaseModel):
name: str
path: str
count: int
# 현재 사용자 기준, 해당 경로 (하위 경로 포함) 의 안 본 자료 수.
# 0 이면 모두 1+회독.
unread_count: int = 0
is_category: bool
is_system: bool
has_children: bool
@@ -301,6 +304,15 @@ async def get_library_tree(
path_docs.setdefault(ancestor, set()).add(doc_id)
seen_ancestors.add(ancestor)
# 2.5 현재 사용자가 1+회독 한 doc_id 집합 (안 본 자료 = 전체 - 읽음)
from models.document_read import DocumentRead
read_result = await session.execute(
select(DocumentRead.document_id)
.where(DocumentRead.user_id == user.id)
.group_by(DocumentRead.document_id)
)
read_doc_ids: set[int] = {r[0] for r in read_result}
# 3. 모든 path 합산 (카테고리 + 태그)
all_paths = set(cat_map.keys()) | set(path_docs.keys())
@@ -323,10 +335,15 @@ async def get_library_tree(
children_dict = data.get("_children", {})
children = build_tree(children_dict, path)
cat = cat_map.get(path)
# path_docs[path] 는 이미 본 노드의 자손 doc 까지 누적되어 있음 (위 ancestor 누적 로직).
# 따라서 unread_count 도 하위 경로 전체 합산 (bottom-up 별도 계산 불필요).
docs_at_path = path_docs.get(path, set())
unread = len(docs_at_path - read_doc_ids)
nodes.append(CategoryTreeNode(
name=name,
path=path,
count=len(path_docs.get(path, set())),
count=len(docs_at_path),
unread_count=unread,
is_category=path in cat_map,
is_system=cat.is_system if cat else False,
has_children=len(children) > 0,
+3
View File
@@ -11,6 +11,7 @@ from api.auth import router as auth_router
from api.config import router as config_router
from api.dashboard import router as dashboard_router
from api.digest import router as digest_router
from api.document_reads import router as document_reads_router
from api.documents import router as documents_router
from api.library import router as library_router
from api.memos import router as memos_router
@@ -100,6 +101,8 @@ app.include_router(setup_router, prefix="/api/setup", tags=["setup"])
app.include_router(config_router, prefix="/api/config", tags=["config"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
# 회독 카운트 — /api/documents/{id}/read* 경로. documents_router 와 prefix 같아 충돌 없음.
app.include_router(document_reads_router, prefix="/api/documents", tags=["document-reads"])
app.include_router(search_router, prefix="/api/search", tags=["search"])
app.include_router(memos_router, prefix="/api/memos", tags=["memos"])
+33
View File
@@ -0,0 +1,33 @@
"""document_reads 테이블 ORM — 자료실 회독 추적.
NOTE: documents 테이블에 user_id 컬럼이 없음 (single-user 가정).
회독 ownership document_reads.user_id 만으로 추적.
multi-user 전환 documents.user_id 추가 별도 ownership check 필요.
설계:
- append-only log. 회독 횟수 = COUNT(*), 마지막 시각 = MAX(read_at).
- 사용자 명시 행동 (버튼 클릭) 으로만 row insert. 자동 +1 금지.
- 같은 user/document 여러 row 허용 (회독 카운트 누적).
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class DocumentRead(Base):
__tablename__ = "document_reads"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
document_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
)
read_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
+22
View File
@@ -0,0 +1,22 @@
-- 174_document_reads.sql
-- 자료실 회독 추적 — append-only 로그 (1/3)
-- plan: ~/.claude/plans/scalable-chasing-stonebraker.md
--
-- 단일 statement (asyncpg 제약). 인덱스는 175, 176 으로 분리.
--
-- 동작 규칙 (사용자 명시):
-- - detail 페이지 진입만으로 자동 +1 금지. 명시 클릭 시에만 row insert.
-- - 같은 날 여러 번 클릭 가능 (각 row).
-- - 회독 횟수 = COUNT(*), 마지막 시각 = MAX(read_at).
-- - documents 에 read_count 컬럼 추가하지 않음. 본 로그만으로 집계.
--
-- ownership:
-- - 현재 documents 테이블에 user_id 없음 (single-user). document_reads.user_id 만으로
-- 사용자 분리. multi-user 전환 시 documents.user_id 추가 후 별도 ownership check 필요.
CREATE TABLE IF NOT EXISTS document_reads (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
read_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,5 @@
-- 175_document_reads_idx_user_doc.sql (2/3)
-- (user_id, document_id) 단건 통계 조회 + DELETE /read/last 의 ORDER BY 인덱스.
CREATE INDEX IF NOT EXISTS idx_document_reads_user_doc
ON document_reads (user_id, document_id);
@@ -0,0 +1,5 @@
-- 176_document_reads_idx_doc_time.sql (3/3)
-- 문서별 최신순 조회 (library 목록 join 의 MAX(read_at) 빠르게).
CREATE INDEX IF NOT EXISTS idx_document_reads_doc_time
ON document_reads (document_id, read_at DESC);