需求: 每个账号可标已读,已读过的文章刷新/重载后不在 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 详情页操作栏加 '标为已读' 按钮(同样乐观)
145 lines
4.5 KiB
Python
145 lines
4.5 KiB
Python
"""/me 当前用户信息 + 翻译配额 + 已读文章。"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
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
|
|
|
|
router = APIRouter(prefix="/me", tags=["me"])
|
|
|
|
|
|
class MeOut(BaseModel):
|
|
id: int
|
|
username: str
|
|
email: str | None
|
|
role: str
|
|
display_name: str | None
|
|
created_at: datetime
|
|
|
|
|
|
class UsageOut(BaseModel):
|
|
month: str
|
|
used_chars: int
|
|
quota_chars: int
|
|
remaining_chars: int
|
|
buffered_quota: int
|
|
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(
|
|
id=user.id,
|
|
username=user.username,
|
|
email=user.email,
|
|
role=user.role.value,
|
|
display_name=user.display_name,
|
|
created_at=user.created_at,
|
|
)
|
|
|
|
|
|
@router.get("/usage", response_model=UsageOut)
|
|
async def usage(
|
|
user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session), # noqa: ARG001
|
|
):
|
|
r = get_redis()
|
|
now = datetime.now(timezone.utc)
|
|
key = f"translation:month:{now:%Y%m}"
|
|
used = int(await r.get(key) or 0)
|
|
quota = settings.tencent_tmt_quota_month
|
|
buffered = int(quota * (1 - settings.tencent_tmt_quota_buffer))
|
|
remaining = max(0, quota - used)
|
|
return UsageOut(
|
|
month=f"{now:%Y%m}",
|
|
used_chars=used,
|
|
quota_chars=quota,
|
|
remaining_chars=remaining,
|
|
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))
|