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 @@
"""core utilities."""

77
backend/app/core/deps.py Normal file
View File

@@ -0,0 +1,77 @@
"""通用依赖:获取当前用户、要求 owner。"""
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt.exceptions import InvalidTokenError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_token, hash_api_token
from app.database import get_session
from app.models.api_token import ApiToken
from app.models.user import User, UserRole
_bearer = HTTPBearer(auto_error=False)
async def _resolve_user(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User:
if creds is None or not creds.credentials:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing credentials")
token = creds.credentials
# 1) 先试 API Token(sha256 比较)
h = hash_api_token(token)
api_row = (
await session.execute(
select(ApiToken).where(ApiToken.token_hash == h, ApiToken.revoked_at.is_(None))
)
.scalars()
.first()
)
if api_row:
if api_row.expires_at and api_row.expires_at < datetime.now(timezone.utc):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token expired")
user = (
await session.execute(select(User).where(User.id == api_row.user_id))
.scalars()
.first()
)
if user and user.is_active:
api_row.last_used_at = datetime.now(timezone.utc)
await session.commit()
return user
# 2) 试 JWT
try:
payload = decode_token(token)
if payload.get("type") != "access":
raise InvalidTokenError("wrong type")
uid = int(payload["sub"])
except (InvalidTokenError, KeyError, ValueError):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
user = (
await session.execute(select(User).where(User.id == uid, User.is_active.is_(True)))
.scalars()
.first()
)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or inactive")
return user
async def get_current_user(user: User = Depends(_resolve_user)) -> User:
return user
async def require_owner(user: User = Depends(get_current_user)) -> User:
if user.role != UserRole.OWNER:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Owner only")
return user

View File

@@ -0,0 +1,73 @@
"""鉴权核心:密码哈希 + JWT 编解码 + API Token。"""
from __future__ import annotations
import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from passlib.context import CryptContext
from app.config import settings
# bcrypt 4.0.1 与 passlib 1.7.4 兼容
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12)
def hash_password(plain: str) -> str:
return pwd_ctx.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
try:
return pwd_ctx.verify(plain, hashed)
except Exception:
return False
# === JWT ===
def create_access_token(subject: str | int, extra: dict[str, Any] | None = None) -> str:
now = datetime.now(timezone.utc)
payload: dict[str, Any] = {
"sub": str(subject),
"type": "access",
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.access_token_ttl_min)).timestamp()),
}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def create_refresh_token(subject: str | int) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(subject),
"type": "refresh",
"iat": int(now.timestamp()),
"exp": int((now + timedelta(days=settings.refresh_token_ttl_day)).timestamp()),
"jti": secrets.token_urlsafe(16),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict[str, Any]:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
# === API Token(给 Android 用)===
def generate_api_token() -> tuple[str, str]:
"""返回 (raw_token, token_hash)。raw_token 只显示一次。"""
raw = secrets.token_urlsafe(32)
return raw, hash_api_token(raw)
def hash_api_token(raw: str) -> str:
# 简单 sha256 即可(随机性已经够)
return hashlib.sha256(raw.encode()).hexdigest()
def constant_time_eq(a: str, b: str) -> bool:
return hmac.compare_digest(a, b)