feat(read): 已读功能 — 每账号标已读,列表默认隐藏

需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。

设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段

前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
    顶部加 '隐藏已读' 开关,默认 ON
    每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
    已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
This commit is contained in:
xiaji
2026-06-13 21:04:47 +08:00
parent 8b3c7caf87
commit 6c71ab2e79
9 changed files with 352 additions and 4 deletions

View File

@@ -1,15 +1,18 @@
"""/me 当前用户信息 + 翻译配额。"""
"""/me 当前用户信息 + 翻译配额 + 已读文章"""
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import and_, delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.core.deps import get_current_user
from app.database import get_session
from app.models.article import Article
from app.models.article_read import ArticleRead
from app.models.user import User
from app.redis_client import get_redis
@@ -34,6 +37,17 @@ class UsageOut(BaseModel):
pct_used: float
class ReadToggleResponse(BaseModel):
article_id: int
is_read: bool
class ReadListResponse(BaseModel):
"""已读文章 ID 列表(给前端用,Feed 列表展示)."""
article_ids: list[int]
total: int
@router.get("", response_model=MeOut)
async def me(user: User = Depends(get_current_user)):
return MeOut(
@@ -66,3 +80,65 @@ async def usage(
buffered_quota=buffered,
pct_used=round(used / quota * 100, 2) if quota else 0.0,
)
# === 已读文章(per-user) ===
@router.post("/reads/{article_id}", response_model=ReadToggleResponse)
async def mark_read(
article_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""标记文章为已读(幂等,重复调用不报错)。"""
# 先确认文章存在(不存在返 404,避免 FK 报错)
exists = (await session.execute(select(Article.id).where(Article.id == article_id))).scalar_one_or_none()
if not exists:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Article not found")
# 用 INSERT ... ON CONFLICT DO NOTHING (PG 原生)— 等价 upsert 跳过
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(ArticleRead).values(user_id=user.id, article_id=article_id).on_conflict_do_nothing(
index_elements=["user_id", "article_id"]
)
await session.execute(stmt)
await session.commit()
return ReadToggleResponse(article_id=article_id, is_read=True)
@router.delete("/reads/{article_id}", response_model=ReadToggleResponse)
async def unmark_read(
article_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""取消已读(幂等)。"""
stmt = delete(ArticleRead).where(
and_(ArticleRead.user_id == user.id, ArticleRead.article_id == article_id)
)
res = await session.execute(stmt)
await session.commit()
return ReadToggleResponse(article_id=article_id, is_read=False)
@router.get("/reads", response_model=ReadListResponse)
async def list_reads(
since: datetime | None = None,
limit: int = 500,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""列出当前用户已读文章 ID(给 Feed 过滤 / 已读时间线用)。
- since: 只返回 read_at >= since 的(默认 7 天前,避免数据爆炸)
- limit: 最多返回多少(默认 500)
"""
if since is None:
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=7)
stmt = (
select(ArticleRead.article_id)
.where(ArticleRead.user_id == user.id, ArticleRead.read_at >= since)
.order_by(ArticleRead.read_at.desc())
.limit(min(limit, 2000))
)
rows = (await session.execute(stmt)).all()
ids = [r[0] for r in rows]
return ReadListResponse(article_ids=ids, total=len(ids))