Initial commit: RSS platform phase 1 skeleton with code review fixes

Features:
- FastAPI + SQLAlchemy 2.0 async + PostgreSQL/pgvector + Redis backend
- Vue 3 + TypeScript + Element Plus frontend
- JWT auth with access/refresh tokens and revocation
- Admin/member RBAC
- RSS feed CRUD and article listing
- Settings management with Fernet encryption for sensitive values
- Redis distributed lock service
- Alembic initial migration
- Docker Compose development environment

Fixes from code review:
- Fix DB session leak in dependency injection
- Restrict registration to admin only
- Add default admin password warning
- Implement JWT refresh tokens and jti blacklist
- Strengthen password policy
- Use func.count for pagination totals
- Replace NullPool with AsyncAdaptedQueuePool
- Remove init_db from lifespan to enforce alembic migrations
- Add request_id middleware and logging filter
- Fix vite.config.ts env loading
- Add frontend token refresh interceptor
- Add Vue error handler

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
congsh
2026-06-15 17:01:57 +08:00
commit ba6e7669e8
82 changed files with 6859 additions and 0 deletions
View File
+55
View File
@@ -0,0 +1,55 @@
"""Admin locks router."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_admin, get_db
from app.models.lock import Lock
from app.models.user import User
from app.schemas.common import MessageResponse
router = APIRouter(prefix="/locks", tags=["admin"])
@router.get("")
async def list_locks(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""List active locks."""
result = await db.execute(select(Lock))
locks = result.scalars().all()
now = datetime.now(timezone.utc)
active_locks = [
{
"id": str(lock.id),
"lock_name": lock.lock_name,
"owner_id": lock.owner_id,
"acquired_at": lock.acquired_at.isoformat() if lock.acquired_at else None,
"expires_at": lock.expires_at.isoformat() if lock.expires_at else None,
"is_expired": lock.expires_at is not None and lock.expires_at < now,
}
for lock in locks
]
return {"total": len(active_locks), "items": active_locks}
@router.delete("/{lock_name}", response_model=MessageResponse)
async def force_release_lock(
lock_name: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Force release a lock."""
result = await db.execute(select(Lock).where(Lock.lock_name == lock_name))
lock = result.scalar_one_or_none()
if not lock:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lock not found")
await db.delete(lock)
await db.commit()
return {"message": f"Lock {lock_name} released"}
+79
View File
@@ -0,0 +1,79 @@
"""Articles router."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.models.article import CleanedArticle
from app.models.user import User
from app.schemas.article import ArticleListParams, ArticleOut
from app.schemas.common import MessageResponse, PaginatedResponse
router = APIRouter(prefix="/articles", tags=["articles"])
@router.get("", response_model=PaginatedResponse)
async def list_articles(
params: ArticleListParams = Depends(),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List cleaned articles with filters."""
query = select(CleanedArticle)
if params.feed_id:
query = query.where(CleanedArticle.feed_id == params.feed_id)
if params.category:
query = query.where(CleanedArticle.category == params.category)
if params.tag:
query = query.where(CleanedArticle.tags.contains([params.tag]))
if params.search:
query = query.where(
CleanedArticle.title.ilike(f"%{params.search}%")
| CleanedArticle.ai_summary.ilike(f"%{params.search}%")
)
if params.is_read is not None:
# CleanedArticle doesn't have is_read in current schema; placeholder
pass
# Count
count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar_one()
# Paginate
query = (
query.offset(params.skip)
.limit(params.limit)
.order_by(CleanedArticle.published_at.desc().nulls_last())
)
result = await db.execute(query)
items = result.scalars().all()
return {
"total": total,
"items": [ArticleOut.model_validate(item) for item in items],
}
@router.get("/{article_id}", response_model=ArticleOut)
async def get_article(
article_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single cleaned article."""
article = await db.get(CleanedArticle, article_id)
if not article:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Article not found")
return ArticleOut.model_validate(article)
@router.put("/{article_id}/read", response_model=MessageResponse)
async def mark_article_read(
article_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Mark an article as read (placeholder)."""
# In Phase 1, cleaned_articles doesn't have is_read field yet
return {"message": "Article marked as read"}
+143
View File
@@ -0,0 +1,143 @@
"""Authentication router."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_admin, get_current_user, get_db
from app.core.auth import (
create_access_token,
create_refresh_token,
decode_token,
get_password_hash,
revoke_token,
verify_password,
)
from app.models.user import User
from app.schemas.user import (
RefreshTokenRequest,
TokenResponse,
UserCreate,
UserLogin,
UserOut,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserOut)
async def register(
user_in: UserCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_admin),
):
"""Register a new user (admin only)."""
# Check if username exists
result = await db.execute(select(User).where(User.username == user_in.username))
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already exists",
)
user = User(
username=user_in.username,
password_hash=get_password_hash(user_in.password),
role=user_in.role,
is_active=user_in.is_active,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post("/login", response_model=TokenResponse)
async def login(
credentials: UserLogin,
db: AsyncSession = Depends(get_db),
):
"""Login and get access/refresh tokens."""
result = await db.execute(select(User).where(User.username == credentials.username))
user = result.scalar_one_or_none()
if not user or not verify_password(credentials.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user",
)
user.last_login_at = datetime.now(timezone.utc)
await db.commit()
access_token, _ = create_access_token(sub=str(user.id), role=user.role)
refresh_token, _ = create_refresh_token(sub=str(user.id))
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
}
@router.post("/refresh", response_model=TokenResponse)
async def refresh(
req: RefreshTokenRequest,
db: AsyncSession = Depends(get_db),
):
"""Exchange a valid refresh token for a new token pair."""
try:
payload = decode_token(req.refresh_token, expected_type="refresh")
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid refresh token: {exc}",
headers={"WWW-Authenticate": "Bearer"},
) from exc
user = await db.get(User, payload["sub"])
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid user",
headers={"WWW-Authenticate": "Bearer"},
)
access_token, _ = create_access_token(sub=str(user.id), role=user.role)
refresh_token, _ = create_refresh_token(sub=str(user.id))
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
}
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
req: RefreshTokenRequest,
):
"""Revoke the provided refresh token."""
try:
payload = decode_token(req.refresh_token, expected_type="refresh")
except ValueError:
return None
exp = payload.get("exp")
if exp:
expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
await revoke_token(payload["jti"], expires_at)
return None
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
"""Get current user info."""
return current_user
+135
View File
@@ -0,0 +1,135 @@
"""Feeds router."""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.models.feed import Feed
from app.models.user import User
from app.schemas.common import MessageResponse, PaginatedResponse, PaginationParams
from app.schemas.feed import FeedCreate, FeedOut, FeedUpdate
router = APIRouter(prefix="/feeds", tags=["feeds"])
@router.get("", response_model=PaginatedResponse)
async def list_feeds(
pagination: PaginationParams = Depends(),
category: str | None = Query(None),
search: str | None = Query(None),
is_active: bool | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List RSS feeds with pagination and filters."""
query = select(Feed)
if category:
query = query.where(Feed.category == category)
if search:
query = query.where(
Feed.title.ilike(f"%{search}%")
| Feed.url.ilike(f"%{search}%")
| Feed.description.ilike(f"%{search}%")
)
if is_active is not None:
query = query.where(Feed.is_active == is_active)
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar_one()
# Get paginated items
query = query.offset(pagination.skip).limit(pagination.limit).order_by(Feed.created_at.desc())
result = await db.execute(query)
items = result.scalars().all()
return {
"total": total,
"items": [FeedOut.model_validate(item) for item in items],
}
@router.get("/{feed_id}", response_model=FeedOut)
async def get_feed(
feed_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single feed by ID."""
feed = await db.get(Feed, feed_id)
if not feed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feed not found")
return FeedOut.model_validate(feed)
@router.post("", response_model=FeedOut, status_code=status.HTTP_201_CREATED)
async def create_feed(
feed_in: FeedCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new RSS feed."""
# Check URL uniqueness
result = await db.execute(select(Feed).where(Feed.url == str(feed_in.url)))
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Feed with this URL already exists",
)
feed = Feed(
url=str(feed_in.url),
title=feed_in.title or "",
description=feed_in.description or "",
category=feed_in.category or "",
is_active=feed_in.is_active,
fetch_interval_minutes=feed_in.fetch_interval_minutes,
priority=feed_in.priority,
parser_config=feed_in.parser_config,
proxy_policy=feed_in.proxy_policy,
)
db.add(feed)
await db.commit()
await db.refresh(feed)
return FeedOut.model_validate(feed)
@router.put("/{feed_id}", response_model=FeedOut)
async def update_feed(
feed_id: str,
feed_in: FeedUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update an existing feed."""
feed = await db.get(Feed, feed_id)
if not feed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feed not found")
update_data = feed_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == "url" and value is not None:
value = str(value)
setattr(feed, field, value)
await db.commit()
await db.refresh(feed)
return FeedOut.model_validate(feed)
@router.delete("/{feed_id}", response_model=MessageResponse)
async def delete_feed(
feed_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a feed."""
feed = await db.get(Feed, feed_id)
if not feed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feed not found")
await db.delete(feed)
await db.commit()
return {"message": "Feed deleted successfully"}
+52
View File
@@ -0,0 +1,52 @@
"""Health check router."""
from fastapi import APIRouter, Depends, Request
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_admin, get_db
from app.core.redis import check_redis_health
router = APIRouter(prefix="/health", tags=["health"])
@router.get("")
async def health_check(request: Request, db: AsyncSession = Depends(get_db)):
"""Basic health check."""
db_ok = False
try:
await db.execute(text("SELECT 1"))
db_ok = True
except Exception:
db_ok = False
redis_ok = await check_redis_health()
status_code = "ok" if db_ok and redis_ok else "degraded"
response = {
"status": status_code,
"service": "rss-platform",
"db": "ok" if db_ok else "error",
"redis": "ok" if redis_ok else "error",
}
warnings = getattr(request.app.state, "startup_warnings", None)
if warnings:
response["warnings"] = warnings
return response
@router.get("/db", dependencies=[Depends(get_current_admin)])
async def db_health(db: AsyncSession = Depends(get_db)):
"""Database health check."""
try:
await db.execute(text("SELECT 1"))
return {"status": "ok", "component": "database"}
except Exception as exc:
return {"status": "error", "component": "database", "detail": str(exc)}
@router.get("/redis", dependencies=[Depends(get_current_admin)])
async def redis_health():
"""Redis health check."""
ok = await check_redis_health()
return {"status": "ok" if ok else "error", "component": "redis"}
+92
View File
@@ -0,0 +1,92 @@
"""Settings router."""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_admin, get_current_user, get_db
from app.models.user import User
from app.schemas.common import MessageResponse
from app.services.settings_service import (
apply_db_settings_to_config,
list_settings,
reset_settings,
set_setting,
)
router = APIRouter(prefix="/settings", tags=["settings"])
@router.get("")
async def get_settings(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all settings."""
return await list_settings(db, mask_sensitive=current_user.role != "admin")
@router.put("/{key}")
async def update_setting(
key: str,
value: dict[str, Any],
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Update a single setting."""
if "value" not in value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Request body must contain 'value' field",
)
success = await set_setting(db, key, value["value"])
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid setting key: {key}",
)
await apply_db_settings_to_config(db)
return {"message": "Setting updated", "key": key}
@router.put("")
async def batch_update_settings(
data: dict[str, Any],
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Update multiple settings."""
settings_data = data.get("settings", {})
if not settings_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Request body must contain 'settings' object",
)
errors = []
for key, value in settings_data.items():
success = await set_setting(db, key, value)
if not success:
errors.append(key)
if errors:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid setting keys: {', '.join(errors)}",
)
await apply_db_settings_to_config(db)
return {"message": "Settings updated", "count": len(settings_data)}
@router.post("/reset", response_model=MessageResponse)
async def reset_all_settings(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Reset all settings to environment defaults."""
await reset_settings(db)
await apply_db_settings_to_config(db)
return {"message": "Settings reset to defaults"}