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
+34
View File
@@ -0,0 +1,34 @@
"""Models package."""
from app.models.ai_config import AIProviderConfig, AITaskConfig
from app.models.article import CleanedArticle, RawArticle
from app.models.base import Base, TimestampMixin, UUIDMixin, utc_now
from app.models.chat import ChatMessage, ChatSession
from app.models.feed import Feed
from app.models.lock import Lock
from app.models.output import Output, OutputTask
from app.models.reference import ArticleReference, DuplicateGroup
from app.models.setting import AppSetting
from app.models.skill import Skill
from app.models.user import User
__all__ = [
"Base",
"TimestampMixin",
"UUIDMixin",
"utc_now",
"User",
"Feed",
"RawArticle",
"CleanedArticle",
"ArticleReference",
"DuplicateGroup",
"Skill",
"AIProviderConfig",
"AITaskConfig",
"OutputTask",
"Output",
"ChatSession",
"ChatMessage",
"Lock",
"AppSetting",
]
+45
View File
@@ -0,0 +1,45 @@
"""AI configuration models."""
from sqlalchemy import Boolean, Float, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
class AIProviderConfig(Base, UUIDMixin, TimestampMixin):
"""AI provider configuration (OpenAI, Anthropic, etc.)."""
__tablename__ = "ai_provider_configs"
name: Mapped[str] = mapped_column(String(128), nullable=False)
provider: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
base_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
api_key_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
default_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
timeout: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
max_retries: Mapped[int] = mapped_column(Integer, default=3, nullable=False)
rate_limit_rpm: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class AITaskConfig(Base, UUIDMixin, TimestampMixin):
"""AI task configuration (which model/skill for which task)."""
__tablename__ = "ai_task_configs"
task_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
provider_config_id: Mapped[str | None] = mapped_column(
ForeignKey("ai_provider_configs.id", ondelete="SET NULL"), nullable=True
)
model: Mapped[str] = mapped_column(String(128), nullable=False)
skill_id: Mapped[str | None] = mapped_column(
ForeignKey("skills.id", ondelete="SET NULL"), nullable=True
)
temperature: Mapped[float] = mapped_column(Float, default=0.3, nullable=False)
max_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
top_p: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
system_prompt_override: Mapped[str | None] = mapped_column(Text, nullable=True)
fallback_config_id: Mapped[str | None] = mapped_column(
ForeignKey("ai_task_configs.id", ondelete="SET NULL"), nullable=True
)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+81
View File
@@ -0,0 +1,81 @@
"""Article models: raw and cleaned."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
class RawArticle(Base, UUIDMixin, TimestampMixin):
"""Raw article fetched from RSS feed."""
__tablename__ = "raw_articles"
feed_id: Mapped[str] = mapped_column(
ForeignKey("feeds.id", ondelete="CASCADE"), nullable=False, index=True
)
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
title: Mapped[str | None] = mapped_column(String(1024), default="", index=True)
link: Mapped[str] = mapped_column(String(2048), nullable=False, index=True)
author: Mapped[str | None] = mapped_column(String(256), default="")
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
content: Mapped[str | None] = mapped_column(Text, default="")
summary: Mapped[str | None] = mapped_column(Text, default="")
raw_html: Mapped[str | None] = mapped_column(Text, default="")
content_hash: Mapped[str | None] = mapped_column(String(64), default="")
language: Mapped[str | None] = mapped_column(String(16), default="")
status: Mapped[str] = mapped_column(String(32), default="pending", nullable=False, index=True)
feed: Mapped["Feed"] = relationship("Feed", back_populates="raw_articles")
cleaned_article: Mapped["CleanedArticle | None"] = relationship(
"CleanedArticle", back_populates="raw_article", uselist=False
)
class CleanedArticle(Base, UUIDMixin, TimestampMixin):
"""Cleaned and AI-enriched article."""
__tablename__ = "cleaned_articles"
raw_article_id: Mapped[str | None] = mapped_column(
ForeignKey("raw_articles.id", ondelete="SET NULL"), nullable=True, index=True
)
feed_id: Mapped[str] = mapped_column(
ForeignKey("feeds.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str | None] = mapped_column(String(1024), default="", index=True)
link: Mapped[str] = mapped_column(String(2048), default="", index=True)
author: Mapped[str | None] = mapped_column(String(256), default="")
feed_title: Mapped[str | None] = mapped_column(String(512), default="")
feed_category: Mapped[str | None] = mapped_column(String(128), default="")
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
content: Mapped[str | None] = mapped_column(Text, default="")
content_length: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
original_summary: Mapped[str | None] = mapped_column(Text, default="")
ai_summary: Mapped[str | None] = mapped_column(Text, default="")
category: Mapped[str | None] = mapped_column(String(128), default="", index=True)
tags: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
heat_score: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
importance_score: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
duplication_score: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
composite_score: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
duplicate_group_id: Mapped[str | None] = mapped_column(
ForeignKey("duplicate_groups.id", ondelete="SET NULL"), nullable=True, index=True
)
is_representative: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
reference_links: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
processing_status: Mapped[str] = mapped_column(String(32), default="pending", nullable=False, index=True)
raw_article: Mapped["RawArticle | None"] = relationship("RawArticle", back_populates="cleaned_article")
duplicate_group: Mapped["DuplicateGroup | None"] = relationship("DuplicateGroup", back_populates="articles")
+45
View File
@@ -0,0 +1,45 @@
"""SQLAlchemy 2.0 async base and session factory."""
from datetime import datetime, timezone
from uuid import uuid4
from sqlalchemy import DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
"""Base class for all models."""
pass
class TimestampMixin:
"""Adds created_at and updated_at columns."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
class UUIDMixin:
"""Adds UUID primary key."""
id: Mapped[str] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid4,
index=True,
)
def utc_now() -> datetime:
"""Return timezone-aware UTC now."""
return datetime.now(timezone.utc)
+42
View File
@@ -0,0 +1,42 @@
"""Chat models."""
from sqlalchemy import ForeignKey, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
class ChatSession(Base, UUIDMixin, TimestampMixin):
"""Chat session."""
__tablename__ = "chat_sessions"
user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
title: Mapped[str | None] = mapped_column(String(256), default="")
skill_id: Mapped[str | None] = mapped_column(
ForeignKey("skills.id", ondelete="SET NULL"), nullable=True
)
context_window: Mapped[int] = mapped_column(default=10, nullable=False)
messages: Mapped[list["ChatMessage"]] = relationship(
"ChatMessage", back_populates="session", cascade="all, delete-orphan"
)
class ChatMessage(Base, UUIDMixin, TimestampMixin):
"""Chat message."""
__tablename__ = "chat_messages"
session_id: Mapped[str] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
role: Mapped[str] = mapped_column(String(32), nullable=False, index=True) # user / assistant / tool
content: Mapped[str | None] = mapped_column(Text, default="")
tool_calls: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
tool_results: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
references: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
token_usage: Mapped[dict | None] = mapped_column(JSON, nullable=True)
session: Mapped["ChatSession"] = relationship("ChatSession", back_populates="messages")
+59
View File
@@ -0,0 +1,59 @@
"""Feed model."""
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
class Feed(Base, UUIDMixin, TimestampMixin):
"""RSS feed source."""
__tablename__ = "feeds"
url: Mapped[str] = mapped_column(String(2048), unique=True, nullable=False, index=True)
title: Mapped[str | None] = mapped_column(String(512), default="")
description: Mapped[str | None] = mapped_column(Text, default="")
category: Mapped[str | None] = mapped_column(String(128), default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
fetch_interval_minutes: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
priority: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
parser_config: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
proxy_policy: Mapped[str] = mapped_column(String(32), default="auto", nullable=False)
# Fetch statistics
last_fetch_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_fetch_status: Mapped[str | None] = mapped_column(String(32), default="")
last_error: Mapped[str | None] = mapped_column(Text, default="")
error_type: Mapped[str | None] = mapped_column(String(64), default="")
success_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
fail_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
article_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
raw_articles: Mapped[list["RawArticle"]] = relationship(
"RawArticle", back_populates="feed", cascade="all, delete-orphan"
)
def health_status(self, now: datetime | None = None) -> str:
"""Compute feed health status."""
if now is None:
now = datetime.now(timezone.utc)
total = self.success_count + self.fail_count
if total == 0:
return "unknown"
success_rate = self.success_count / total
days_since = None
if self.last_fetch_at:
days_since = (now - self.last_fetch_at).days
if success_rate >= 0.9 and (days_since is None or days_since <= 7):
return "healthy"
if success_rate >= 0.5 and (days_since is None or days_since <= 7):
return "warning"
return "unhealthy"
def __repr__(self) -> str:
return f"<Feed {self.title or self.url}>"
+24
View File
@@ -0,0 +1,24 @@
"""Lock model."""
from datetime import datetime, timezone
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, UUIDMixin
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
class Lock(Base, UUIDMixin):
"""Distributed lock record (fallback when Redis is unavailable)."""
__tablename__ = "locks"
lock_name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
owner_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
acquired_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_utc_now
)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+41
View File
@@ -0,0 +1,41 @@
"""Output task and output record models."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
class OutputTask(Base, UUIDMixin, TimestampMixin):
"""Configurable output task (e.g. daily brief)."""
__tablename__ = "output_tasks"
name: Mapped[str] = mapped_column(String(128), nullable=False)
task_type: Mapped[str] = mapped_column(String(64), default="daily_brief", nullable=False, index=True)
skill_id: Mapped[str] = mapped_column(
ForeignKey("skills.id", ondelete="CASCADE"), nullable=False
)
schedule: Mapped[str | None] = mapped_column(String(128), nullable=True) # cron expression
filter_config: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
output_config: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_output_id: Mapped[str | None] = mapped_column(
ForeignKey("outputs.id", ondelete="SET NULL"), nullable=True
)
class Output(Base, UUIDMixin, TimestampMixin):
"""Generated output record."""
__tablename__ = "outputs"
output_task_id: Mapped[str | None] = mapped_column(
ForeignKey("output_tasks.id", ondelete="SET NULL"), nullable=True, index=True
)
content: Mapped[str | None] = mapped_column(Text, default="")
content_html: Mapped[str | None] = mapped_column(Text, default="")
references: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
metadata: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
+39
View File
@@ -0,0 +1,39 @@
"""Reference and duplicate group models."""
from sqlalchemy import Float, ForeignKey, JSON, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
class ArticleReference(Base, UUIDMixin, TimestampMixin):
"""Reference from a cleaned article to another related article."""
__tablename__ = "article_references"
source_article_id: Mapped[str] = mapped_column(
ForeignKey("cleaned_articles.id", ondelete="CASCADE"), nullable=False, index=True
)
referenced_article_id: Mapped[str | None] = mapped_column(
ForeignKey("cleaned_articles.id", ondelete="SET NULL"), nullable=True, index=True
)
reference_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
reference_link: Mapped[str | None] = mapped_column(String(2048), default="")
reference_title: Mapped[str | None] = mapped_column(String(1024), default="")
similarity: Mapped[float | None] = mapped_column(Float, nullable=True)
class DuplicateGroup(Base, UUIDMixin, TimestampMixin):
"""Group of duplicate articles."""
__tablename__ = "duplicate_groups"
representative_article_id: Mapped[str | None] = mapped_column(
ForeignKey("cleaned_articles.id", ondelete="SET NULL"), nullable=True, index=True
)
member_article_ids: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
similarity_matrix: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
brief_date: Mapped[str | None] = mapped_column(String(10), default="", index=True)
articles: Mapped[list["CleanedArticle"]] = relationship(
"CleanedArticle", back_populates="duplicate_group"
)
+16
View File
@@ -0,0 +1,16 @@
"""App setting model."""
from sqlalchemy import Boolean, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
class AppSetting(Base, UUIDMixin, TimestampMixin):
"""Runtime application setting."""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
value: Mapped[str] = mapped_column(Text, default="", nullable=False)
description: Mapped[str | None] = mapped_column(Text, default="")
is_sensitive: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+26
View File
@@ -0,0 +1,26 @@
"""Skill model."""
from sqlalchemy import Boolean, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
class Skill(Base, UUIDMixin, TimestampMixin):
"""Reusable skill configuration for AI outputs."""
__tablename__ = "skills"
name: Mapped[str] = mapped_column(String(128), nullable=False)
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, default="")
type: Mapped[str] = mapped_column(String(32), nullable=False, index=True) # output / tool / agent
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
output_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
tools: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
input_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
example_inputs: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
+22
View File
@@ -0,0 +1,22 @@
"""User model."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin, utc_now
class User(Base, UUIDMixin, TimestampMixin):
"""Platform user."""
__tablename__ = "users"
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(32), default="member", nullable=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
def __repr__(self) -> str:
return f"<User {self.username} ({self.role})>"