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:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user