Files
congsh ba6e7669e8 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>
2026-06-15 17:01:57 +08:00

136 lines
4.3 KiB
Python

"""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"}