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:
Mavis
2026-06-07 21:51:01 +08:00
commit 60b062daf2
81 changed files with 5540 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""API routes."""

199
backend/app/api/admin.py Normal file
View 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
View 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
View 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)

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

View 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]

View 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