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

@@ -0,0 +1,54 @@
"""文章已读记录(per-user)
- article_reads.user_id BIGINT FK users.id ON DELETE CASCADE
- article_reads.article_id BIGINT FK articles.id ON DELETE CASCADE
- article_reads.read_at TIMESTAMPTZ DEFAULT now()
- 复合主键 (user_id, article_id) — 天然幂等
Revision ID: 0007
Revises: 0006
Create Date: 2026-06-13
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0007"
down_revision: Union[str, None] = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"article_reads",
sa.Column("user_id", sa.BigInteger, nullable=False),
sa.Column("article_id", sa.BigInteger, nullable=False),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.PrimaryKeyConstraint("user_id", "article_id", name="pk_article_reads"),
sa.ForeignKeyConstraint(
"user_id", ["users.id"], ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
"article_id", ["articles.id"], ondelete="CASCADE",
),
)
op.create_index(
"ix_article_reads_user_read_at",
"article_reads",
["user_id", "read_at"],
)
op.create_index(
"ix_article_reads_article",
"article_reads",
["article_id"],
)
def downgrade() -> None:
op.drop_index("ix_article_reads_article", table_name="article_reads")
op.drop_index("ix_article_reads_user_read_at", table_name="article_reads")
op.drop_table("article_reads")

View File

@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
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.bookmark import Bookmark
from app.models.source import Source
from app.models.user import User
@@ -35,6 +36,7 @@ async def list_articles(
page: int = Query(default=1, ge=1, description="页码(从 1 开始)"),
page_size: int = Query(default=50, ge=1, le=200, description="每页条数"),
starred_only: bool = False,
hide_read: bool = Query(default=True, description="是否隐藏已读文章(默认 true)"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
@@ -64,6 +66,18 @@ async def list_articles(
if lang == "zh":
filters.append(Article.title_zh.is_not(None))
if hide_read:
# LEFT JOIN article_reads,WHERE 排除已读
# 注意:这里用 NOT EXISTS 子查询更干净(避免影响 count/select 的 JOIN 链)
from sqlalchemy import not_, exists
filters.append(
not_(
select(ArticleRead.article_id)
.where(ArticleRead.user_id == user.id, ArticleRead.article_id == Article.id)
.exists()
)
)
# ===== count 总数 =====
count_stmt = select(func.count(Article.id)).join(Source, Source.id == Article.source_id)
for f in filters:
@@ -97,9 +111,10 @@ async def list_articles(
rows = (await session.execute(stmt)).all()
# 标记 is_starred(批量)
# 标记 is_starred(批量)+ is_read(批量)
ids = [a.id for a, _ in rows]
starred_ids: set[int] = set()
read_ids: set[int] = set()
if ids:
bm_rows = (
await session.execute(
@@ -109,6 +124,15 @@ async def list_articles(
)
).all()
starred_ids = {b[0] for b in bm_rows}
# 查 is_read(无论 hide_read 都查,前端可拿"已读"标识,例如"显示已读"开关打开时仍要知道哪些是已读的)
rd_rows = (
await session.execute(
select(ArticleRead.article_id).where(
ArticleRead.user_id == user.id, ArticleRead.article_id.in_(ids)
)
)
).all()
read_ids = {r[0] for r in rd_rows}
items = []
for art, src in rows:
@@ -134,6 +158,7 @@ async def list_articles(
commentary_engine=art.commentary_engine,
image_ai_url=art.image_ai_url,
is_starred=art.id in starred_ids,
is_read=art.id in read_ids,
)
)
@@ -170,6 +195,14 @@ async def get_article(
)
).first() is not None
is_read = (
await session.execute(
select(ArticleRead.article_id).where(
ArticleRead.user_id == user.id, ArticleRead.article_id == article.id
)
)
).first() is not None
return ArticleDetail(
id=article.id,
source=SourceBrief.model_validate(source),
@@ -207,6 +240,7 @@ async def get_article(
published_at=article.published_at,
fetched_at=article.fetched_at,
is_starred=is_starred,
is_read=is_read,
)

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))

View File

@@ -4,6 +4,7 @@
"""
from app.models.api_token import ApiToken # noqa: F401
from app.models.article import Article # noqa: F401
from app.models.article_read import ArticleRead # noqa: F401
from app.models.bookmark import Bookmark # noqa: F401
from app.models.llm_setting import LlmSetting # noqa: F401
from app.models.source import Source, SourceKind # noqa: F401
@@ -13,6 +14,7 @@ from app.models.user import User, UserRole # noqa: F401
__all__ = [
"ApiToken",
"Article",
"ArticleRead",
"Bookmark",
"LlmSetting",
"Source",

View File

@@ -0,0 +1,46 @@
"""文章已读记录(per-user)。
设计:
- 复合主键 (user_id, article_id) — 天然防重复,标记已读是幂等的
- read_at 默认 now(),用于排序(最近已读在前)
- 索引: (user_id, read_at DESC) — 用户查自己最近已读用
(article_id) — 反向查"谁读过这文章"(暂不用,留扩展)
注意:
- 删文章时 ondelete=CASCADE 自动清掉已读记录
- 这是和 bookmarks 并列的"用户行为"
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ArticleRead(Base):
__tablename__ = "article_reads"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
article_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("articles.id", ondelete="CASCADE"),
primary_key=True,
)
read_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
__table_args__ = (
Index("ix_article_reads_user_read_at", "user_id", "read_at"),
Index("ix_article_reads_article", "article_id"),
)
def __repr__(self) -> str:
return f"<ArticleRead user={self.user_id} article={self.article_id} at={self.read_at}>"

View File

@@ -42,6 +42,7 @@ class ArticleListItem(BaseModel):
commentary_engine: str | None = None # angel / meituan / "angel,meituan"
image_ai_url: str | None = None # AI 插图(列表里缩略图)
is_starred: bool = False
is_read: bool = False
class ArticleDetail(BaseModel):
@@ -84,6 +85,7 @@ class ArticleDetail(BaseModel):
published_at: datetime | None = None
fetched_at: datetime
is_starred: bool = False
is_read: bool = False
class ArticleListResponse(BaseModel):