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