feat: initial MVP - FastAPI backend + Vue3 frontend + docker-compose
- backend: FastAPI + SQLAlchemy 2.0(async) + asyncpg + Alembic - 7 API routes: auth/me/articles/sources/bookmarks/subscriptions/admin - models: User/Source/Article/Bookmark/Subscription/ApiToken - services: RSS fetcher (feedparser) + Tencent TMT translator with quota + cache + local NLLB fallback - workers: APScheduler + asyncio pipeline (fetch -> dedupe -> insert -> translate) - seed scripts: create_user, seed_sources (5 RSS: Reuters/BBC/Al Jazeera/NHK/DW) - frontend: Vue 3 + Vite + Naive UI + Pinia + vue-router - pages: Login, Feed (24h), ArticleDetail, Sources, Bookmarks, AdminSources - deploy: docker-compose (postgres/redis/api/worker/frontend/caddy) - docs: README, DEPLOY, architecture, acceptance
This commit is contained in:
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routes."""
|
||||
199
backend/app/api/admin.py
Normal file
199
backend/app/api/admin.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Admin API(仅 owner)。
|
||||
|
||||
- 源管理 CRUD
|
||||
- 手动触发抓取 / 重译
|
||||
- 源健康看板
|
||||
- 翻译配额管理
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import require_owner
|
||||
from app.database import get_session
|
||||
from app.models.article import Article
|
||||
from app.models.source import Source
|
||||
from app.models.user import User
|
||||
from app.schemas.source import SourceIn, SourceOut, SourceUpdate
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_owner)])
|
||||
|
||||
|
||||
# === Source CRUD ===
|
||||
@router.get("/sources", response_model=list[SourceOut])
|
||||
async def list_sources_all(session: AsyncSession = Depends(get_session)):
|
||||
rows = (await session.execute(select(Source).order_by(Source.id))).scalars()
|
||||
return [SourceOut.model_validate(s) for s in rows]
|
||||
|
||||
|
||||
@router.post("/sources", response_model=SourceOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_source(body: SourceIn, session: AsyncSession = Depends(get_session)):
|
||||
src = Source(
|
||||
name=body.name,
|
||||
slug=body.slug,
|
||||
kind=body.kind,
|
||||
url=str(body.url),
|
||||
detail_selector=body.detail_selector,
|
||||
region=body.region,
|
||||
language_src=body.language_src,
|
||||
priority=body.priority,
|
||||
fetch_interval_min=body.fetch_interval_min,
|
||||
translate_to=body.translate_to,
|
||||
enabled=body.enabled,
|
||||
headers_json=body.headers_json,
|
||||
)
|
||||
session.add(src)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as e:
|
||||
await session.rollback()
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"slug '{body.slug}' already exists") from e
|
||||
await session.refresh(src)
|
||||
return SourceOut.model_validate(src)
|
||||
|
||||
|
||||
@router.patch("/sources/{source_id}", response_model=SourceOut)
|
||||
async def update_source(
|
||||
source_id: int,
|
||||
body: SourceUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
src = (await session.execute(select(Source).where(Source.id == source_id))).scalar_one_or_none()
|
||||
if not src:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Source not found")
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(src, k, v)
|
||||
await session.commit()
|
||||
await session.refresh(src)
|
||||
return SourceOut.model_validate(src)
|
||||
|
||||
|
||||
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_source(source_id: int, session: AsyncSession = Depends(get_session)):
|
||||
src = (await session.execute(select(Source).where(Source.id == source_id))).scalar_one_or_none()
|
||||
if not src:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Source not found")
|
||||
await session.delete(src)
|
||||
await session.commit()
|
||||
return None
|
||||
|
||||
|
||||
# === 手动触发 ===
|
||||
class TriggerResponse(BaseModel):
|
||||
triggered: bool
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@router.post("/refresh/{source_id}", response_model=TriggerResponse)
|
||||
async def refresh_source(
|
||||
source_id: int,
|
||||
background: BackgroundTasks,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
src = (await session.execute(select(Source).where(Source.id == source_id))).scalar_one_or_none()
|
||||
if not src:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Source not found")
|
||||
if not src.enabled:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Source disabled")
|
||||
|
||||
# 走 background,不等结果
|
||||
from app.workers.pipeline import fetch_one_source
|
||||
|
||||
background.add_task(fetch_one_source, source_id)
|
||||
return TriggerResponse(triggered=True, detail=f"queued fetch for {src.slug}")
|
||||
|
||||
|
||||
async def _run_fetch(source_id: int) -> None:
|
||||
"""(deprecated) 走 background 用的薄包装,见 refresh_source。"""
|
||||
from app.workers.pipeline import fetch_one_source
|
||||
|
||||
await fetch_one_source(source_id)
|
||||
|
||||
|
||||
@router.post("/translation/rerun/{article_id}", response_model=TriggerResponse)
|
||||
async def rerun_translation(
|
||||
article_id: int,
|
||||
background: BackgroundTasks,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
art = (await session.execute(select(Article).where(Article.id == article_id))).scalar_one_or_none()
|
||||
if not art:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Article not found")
|
||||
art.translation_status = "pending"
|
||||
art.title_zh = None
|
||||
art.body_zh_text = None
|
||||
art.body_zh_html = None
|
||||
art.translated_at = None
|
||||
art.translation_engine = None
|
||||
await session.commit()
|
||||
|
||||
from app.workers.pipeline import translate_article
|
||||
|
||||
background.add_task(translate_article, article_id)
|
||||
return TriggerResponse(triggered=True, detail=f"queued translation for article {article_id}")
|
||||
|
||||
|
||||
# === 健康看板 ===
|
||||
class HealthOut(BaseModel):
|
||||
source_id: int
|
||||
slug: str
|
||||
name: str
|
||||
enabled: bool
|
||||
last_fetched_at: datetime | None
|
||||
last_status: str | None
|
||||
consecutive_failures: int
|
||||
fetch_interval_min: int
|
||||
article_count_24h: int
|
||||
|
||||
|
||||
@router.get("/health", response_model=list[HealthOut])
|
||||
async def health(session: AsyncSession = Depends(get_session)):
|
||||
rows = (await session.execute(select(Source).order_by(Source.priority.desc()))).scalars()
|
||||
out: list[HealthOut] = []
|
||||
for s in rows:
|
||||
c24 = (
|
||||
await session.execute(
|
||||
select(func.count(Article.id)).where(
|
||||
Article.source_id == s.id,
|
||||
Article.fetched_at >= datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
- timedelta(hours=24),
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
out.append(
|
||||
HealthOut(
|
||||
source_id=s.id,
|
||||
slug=s.slug,
|
||||
name=s.name,
|
||||
enabled=s.enabled,
|
||||
last_fetched_at=s.last_fetched_at,
|
||||
last_status=s.last_status,
|
||||
consecutive_failures=s.consecutive_failures,
|
||||
fetch_interval_min=s.fetch_interval_min,
|
||||
article_count_24h=c24 or 0,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# === 翻译配额(管理员视图) ===
|
||||
class QuotaReset(BaseModel):
|
||||
used_chars: int = 0
|
||||
|
||||
|
||||
@router.post("/translation/quota/reset")
|
||||
async def reset_quota(payload: QuotaReset) -> dict[str, Any]:
|
||||
from app.redis_client import get_redis
|
||||
|
||||
r = get_redis()
|
||||
now = datetime.now(timezone.utc)
|
||||
key = f"translation:month:{now:%Y%m}"
|
||||
await r.set(key, payload.used_chars)
|
||||
return {"key": key, "value": payload.used_chars}
|
||||
194
backend/app/api/articles.py
Normal file
194
backend/app/api/articles.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""/articles 列表与详情。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
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.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"])
|
||||
|
||||
|
||||
def _encode_cursor(article: Article) -> str:
|
||||
payload = {"id": article.id, "ts": int(article.fetched_at.timestamp())}
|
||||
return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
||||
|
||||
|
||||
def _decode_cursor(cur: str) -> tuple[int, datetime]:
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode(cur.encode()).decode())
|
||||
return int(data["id"]), datetime.fromtimestamp(int(data["ts"]))
|
||||
except Exception:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid cursor")
|
||||
|
||||
|
||||
@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",
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
cursor: str | None = None,
|
||||
starred_only: bool = False,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
stmt = (
|
||||
select(Article, Source)
|
||||
.join(Source, Source.id == Article.source_id)
|
||||
.where(Article.duplicate_of.is_(None))
|
||||
)
|
||||
|
||||
# 默认过去 24h
|
||||
if since is None and until is None and cursor is None:
|
||||
since = _default_since_24h()
|
||||
|
||||
if since:
|
||||
stmt = stmt.where(Article.published_at >= since)
|
||||
if until:
|
||||
stmt = stmt.where(Article.published_at <= until)
|
||||
if category:
|
||||
stmt = stmt.where(Article.category == category)
|
||||
|
||||
if source:
|
||||
slugs = [s.strip() for s in source.split(",") if s.strip()]
|
||||
if slugs:
|
||||
stmt = stmt.where(Source.slug.in_(slugs))
|
||||
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
stmt = stmt.where(or_(Article.title.ilike(like), Article.body_text.ilike(like)))
|
||||
|
||||
# 语言过滤
|
||||
if lang == "zh":
|
||||
stmt = stmt.where(Article.title_zh.is_not(None))
|
||||
elif lang == "src":
|
||||
# 只要原文已有
|
||||
pass
|
||||
|
||||
if cursor:
|
||||
last_id, _ = _decode_cursor(cursor)
|
||||
stmt = stmt.where(Article.id < last_id)
|
||||
|
||||
if starred_only:
|
||||
stmt = stmt.join(Bookmark, and_(Bookmark.article_id == Article.id, Bookmark.user_id == user.id))
|
||||
|
||||
stmt = stmt.order_by(desc(Article.published_at), desc(Article.id)).limit(limit + 1)
|
||||
|
||||
rows = (await session.execute(stmt)).all()
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
|
||||
# 标记 is_starred(批量)
|
||||
ids = [a.id for a, _ in rows]
|
||||
starred_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}
|
||||
|
||||
items = []
|
||||
for art, src in rows:
|
||||
item = ArticleListItem(
|
||||
id=art.id,
|
||||
source=SourceBrief.model_validate(src),
|
||||
title=art.title,
|
||||
title_zh=art.title_zh,
|
||||
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,
|
||||
is_starred=art.id in starred_ids,
|
||||
)
|
||||
items.append(item)
|
||||
|
||||
next_cursor = _encode_cursor(rows[-1][0]) if has_more and rows else None
|
||||
return ArticleListResponse(items=items, next_cursor=next_cursor, total=None)
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
art = (
|
||||
await session.execute(
|
||||
select(Article, Source)
|
||||
.join(Source, Source.id == Article.source_id)
|
||||
.where(Article.id == article_id)
|
||||
)
|
||||
.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
|
||||
|
||||
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,
|
||||
summary_zh=article.summary_zh,
|
||||
lang_src=article.lang_src,
|
||||
author=article.author,
|
||||
image_url=article.image_url,
|
||||
translation_status=article.translation_status,
|
||||
translation_engine=article.translation_engine,
|
||||
translated_at=article.translated_at,
|
||||
category=article.category,
|
||||
commentary=article.commentary,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _default_since_24h() -> datetime:
|
||||
from datetime import timedelta
|
||||
|
||||
return datetime.utcnow() - timedelta(hours=24)
|
||||
65
backend/app/api/auth.py
Normal file
65
backend/app/api/auth.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""登录/刷新/登出。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
verify_password,
|
||||
)
|
||||
from app.database import get_session
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, RefreshRequest, TokenPair
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _pair_for(user: User) -> TokenPair:
|
||||
access = create_access_token(user.id, extra={"role": user.role.value})
|
||||
refresh = create_refresh_token(user.id)
|
||||
return TokenPair(
|
||||
access_token=access,
|
||||
refresh_token=refresh,
|
||||
expires_in=settings.access_token_ttl_min * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenPair)
|
||||
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)):
|
||||
user = (
|
||||
await session.execute(select(User).where(User.username == body.username))
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
if not user or not user.is_active or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return _pair_for(user)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenPair)
|
||||
async def refresh(body: RefreshRequest, session: AsyncSession = Depends(get_session)):
|
||||
try:
|
||||
payload = decode_token(body.refresh_token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise InvalidTokenError("wrong type")
|
||||
uid = int(payload["sub"])
|
||||
except (InvalidTokenError, KeyError, ValueError):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid refresh token")
|
||||
user = (
|
||||
await session.execute(select(User).where(User.id == uid, User.is_active.is_(True)))
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found")
|
||||
return _pair_for(user)
|
||||
73
backend/app/api/bookmarks.py
Normal file
73
backend/app/api/bookmarks.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""/bookmarks 收藏。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import 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.bookmark import Bookmark
|
||||
from app.models.user import User
|
||||
from app.schemas.misc import BookmarkIn, BookmarkOut
|
||||
|
||||
router = APIRouter(prefix="/bookmarks", tags=["bookmarks"])
|
||||
|
||||
|
||||
@router.post("", response_model=BookmarkOut, status_code=status.HTTP_201_CREATED)
|
||||
async def add(
|
||||
body: BookmarkIn,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
art = (await session.execute(select(Article).where(Article.id == body.article_id))).scalar_one_or_none()
|
||||
if not art:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Article not found")
|
||||
# 已存在则直接返回
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(Bookmark).where(
|
||||
Bookmark.user_id == user.id, Bookmark.article_id == body.article_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
return BookmarkOut.model_validate(existing)
|
||||
bm = Bookmark(user_id=user.id, article_id=body.article_id, note=body.note)
|
||||
session.add(bm)
|
||||
await session.commit()
|
||||
await session.refresh(bm)
|
||||
return BookmarkOut.model_validate(bm)
|
||||
|
||||
|
||||
@router.delete("/{article_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove(
|
||||
article_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
bm = (
|
||||
await session.execute(
|
||||
select(Bookmark).where(
|
||||
Bookmark.user_id == user.id, Bookmark.article_id == article_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if bm:
|
||||
await session.delete(bm)
|
||||
await session.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("", response_model=list[BookmarkOut])
|
||||
async def list_mine(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Bookmark).where(Bookmark.user_id == user.id).order_by(Bookmark.created_at.desc())
|
||||
)
|
||||
).scalars()
|
||||
return [BookmarkOut.model_validate(b) for b in rows]
|
||||
68
backend/app/api/me.py
Normal file
68
backend/app/api/me.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""/me 当前用户信息 + 翻译配额。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
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.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
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
25
backend/app/api/sources.py
Normal file
25
backend/app/api/sources.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""/sources 源列表(只读,所有登录用户可看)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.database import get_session
|
||||
from app.models.source import Source
|
||||
from app.models.user import User
|
||||
from app.schemas.source import SourceOut
|
||||
|
||||
router = APIRouter(prefix="/sources", tags=["sources"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[SourceOut])
|
||||
async def list_sources(
|
||||
user: User = Depends(get_current_user), # noqa: ARG001
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
rows = (
|
||||
await session.execute(select(Source).order_by(Source.priority.desc(), Source.name))
|
||||
).scalars()
|
||||
return [SourceOut.model_validate(s) for s in rows]
|
||||
68
backend/app/api/subscriptions.py
Normal file
68
backend/app/api/subscriptions.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""/subscriptions 关键词订阅。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.database import get_session
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.user import User
|
||||
from app.schemas.misc import SubscriptionIn, SubscriptionOut
|
||||
|
||||
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
|
||||
|
||||
|
||||
@router.post("", response_model=SubscriptionOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create(
|
||||
body: SubscriptionIn,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
sub = Subscription(
|
||||
user_id=user.id,
|
||||
keyword=body.keyword,
|
||||
match_in=body.match_in,
|
||||
channel=body.channel,
|
||||
target=body.target,
|
||||
)
|
||||
session.add(sub)
|
||||
await session.commit()
|
||||
await session.refresh(sub)
|
||||
return SubscriptionOut.model_validate(sub)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SubscriptionOut])
|
||||
async def list_mine(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.user_id == user.id)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
)
|
||||
).scalars()
|
||||
return [SubscriptionOut.model_validate(s) for s in rows]
|
||||
|
||||
|
||||
@router.delete("/{sub_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete(
|
||||
sub_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
sub = (
|
||||
await session.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.id == sub_id, Subscription.user_id == user.id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not sub:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
|
||||
await session.delete(sub)
|
||||
await session.commit()
|
||||
return None
|
||||
Reference in New Issue
Block a user