Files
diary-news/backend/app/api/articles.py
xiaji 6c71ab2e79 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 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00

251 lines
8.7 KiB
Python

"""/articles 列表与详情。"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, desc, func, or_, select
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
from app.schemas.article import (
ArticleDetail,
ArticleListItem,
ArticleListResponse,
SourceBrief,
)
router = APIRouter(prefix="/articles", tags=["articles"])
@router.get("", response_model=ArticleListResponse)
async def list_articles(
since: datetime | None = Query(default=None, description="起时间 UTC"),
until: datetime | None = Query(default=None, description="止时间 UTC"),
source: str | None = Query(default=None, description="逗号分隔 source slug"),
category: str | None = None,
q: str | None = Query(default=None, description="标题/正文搜索"),
lang: Annotated[str, Query(pattern=r"^(src|zh|both)$")] = "both",
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),
):
# 公共筛选条件(用于 list + count)
filters = [Article.duplicate_of.is_(None)]
# 默认过去 24h
if since is None and until is None:
since = _default_since_24h()
if since:
filters.append(Article.published_at >= since)
if until:
filters.append(Article.published_at <= until)
if category:
filters.append(Article.category == category)
if source:
slugs = [s.strip() for s in source.split(",") if s.strip()]
if slugs:
filters.append(Source.slug.in_(slugs))
if q:
like = f"%{q}%"
filters.append(or_(Article.title.ilike(like), Article.body_text.ilike(like)))
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:
count_stmt = count_stmt.where(f)
if starred_only:
count_stmt = count_stmt.join(
Bookmark, and_(Bookmark.article_id == Article.id, Bookmark.user_id == user.id)
)
total: int = (await session.execute(count_stmt)).scalar_one()
total_pages = max(1, (total + page_size - 1) // page_size)
# 越界保护:page 超出范围时返回空数组,total 仍真实
if page > total_pages:
return ArticleListResponse(
items=[], page=page, page_size=page_size, total=total, total_pages=total_pages
)
# ===== 当前页数据 =====
stmt = select(Article, Source).join(Source, Source.id == Article.source_id)
for f in filters:
stmt = stmt.where(f)
if starred_only:
stmt = stmt.join(Bookmark, and_(Bookmark.article_id == Article.id, Bookmark.user_id == user.id))
offset = (page - 1) * page_size
stmt = (
stmt.order_by(desc(Article.published_at), desc(Article.id))
.offset(offset)
.limit(page_size)
)
rows = (await session.execute(stmt)).all()
# 标记 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(
select(Bookmark.article_id).where(
Bookmark.user_id == user.id, Bookmark.article_id.in_(ids)
)
)
).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:
items.append(
ArticleListItem(
id=art.id,
source=SourceBrief.model_validate(src),
title=art.title,
title_zh=art.title_zh,
body_zh_text=art.body_zh_text,
summary_zh=art.summary_zh,
lang_src=art.lang_src,
translation_status=art.translation_status,
category=art.category,
published_at=art.published_at,
fetched_at=art.fetched_at,
image_url=art.image_url,
# 列表预览钩子:分类 + LLM 点评(双 provider) + AI 插图 缩略图
commentary=art.commentary,
commentary_status=art.commentary_status,
commentary_meituan=art.commentary_meituan,
commentary_meituan_status=art.commentary_meituan_status,
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,
)
)
return ArticleListResponse(
items=items,
page=page,
page_size=page_size,
total=total,
total_pages=total_pages,
)
@router.get("/{article_id}", response_model=ArticleDetail)
async def get_article(
article_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(Article, Source)
.join(Source, Source.id == Article.source_id)
.where(Article.id == article_id)
)
art = result.first()
if not art:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Article not found")
article, source = art
is_starred = (
await session.execute(
select(Bookmark.id).where(
Bookmark.user_id == user.id, Bookmark.article_id == article.id
)
)
).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),
url=article.url,
title=article.title,
body_html=article.body_html,
body_text=article.body_text,
title_zh=article.title_zh,
body_zh_html=article.body_zh_html,
body_zh_text=article.body_zh_text,
body_zh_formatted=article.body_zh_formatted,
summary_zh=article.summary_zh,
lang_src=article.lang_src,
author=article.author,
image_url=article.image_url,
image_ai_url=article.image_ai_url,
translation_status=article.translation_status,
translation_engine=article.translation_engine,
translated_at=article.translated_at,
category=article.category,
format_status=article.format_status,
classify_status=article.classify_status,
image_ai_status=article.image_ai_status,
# 双 provider 评论
commentary_status=article.commentary_status,
commentary=article.commentary,
commentary_engine=article.commentary_engine,
commentary_meituan_status=article.commentary_meituan_status,
commentary_meituan=article.commentary_meituan,
commentary_meituan_model=article.commentary_meituan_model,
commentary_meituan_error=article.commentary_meituan_error,
entities=article.entities,
sentiment=article.sentiment,
duplicate_of=article.duplicate_of,
published_at=article.published_at,
fetched_at=article.fetched_at,
is_starred=is_starred,
is_read=is_read,
)
def _default_since_24h() -> datetime:
from datetime import timedelta
return datetime.utcnow() - timedelta(hours=24)