Files
diary-news/backend/alembic/versions/0001_initial.py
Mavis 60b062daf2 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
2026-06-07 21:51:01 +08:00

181 lines
8.3 KiB
Python

"""initial schema
Revision ID: 0001
Revises:
Create Date: 2026-06-07
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# === 用户 ===
user_role = postgresql.ENUM("owner", "member", name="user_role", create_type=True)
user_role.create(op.get_bind(), checkfirst=True)
op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("username", sa.String(64), unique=True, index=True, nullable=False),
sa.Column("email", sa.String(255), unique=True, index=True),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column(
"role",
postgresql.ENUM("owner", "member", name="user_role", create_type=False),
nullable=False,
),
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
sa.Column("display_name", sa.String(128)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("last_login_at", sa.DateTime(timezone=True)),
)
# === 源 ===
source_kind = postgresql.ENUM("rss", "html_list", "tg_channel", name="source_kind", create_type=True)
source_kind.create(op.get_bind(), checkfirst=True)
op.create_table(
"sources",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("slug", sa.String(128), unique=True, index=True, nullable=False),
sa.Column(
"kind",
postgresql.ENUM("rss", "html_list", "tg_channel", name="source_kind", create_type=False),
nullable=False,
),
sa.Column("url", sa.Text, nullable=False),
sa.Column("detail_selector", postgresql.JSONB),
sa.Column("fetch_interval_min", sa.Integer, nullable=False, server_default="60"),
sa.Column("fetch_cron", sa.String(64)),
sa.Column("translate_to", sa.String(8), nullable=False, server_default="zh"),
sa.Column("enabled", sa.Boolean, nullable=False, server_default=sa.text("true")),
sa.Column("region", sa.String(32), index=True),
sa.Column("language_src", sa.String(8)),
sa.Column("priority", sa.Integer, nullable=False, server_default="50", index=True),
sa.Column("headers_json", postgresql.JSONB),
sa.Column("last_fetched_at", sa.DateTime(timezone=True)),
sa.Column("last_status", sa.String(64)),
sa.Column("consecutive_failures", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# === 文章 ===
op.create_table(
"articles",
sa.Column("id", sa.BigInteger, primary_key=True),
sa.Column("source_id", sa.Integer, sa.ForeignKey("sources.id", ondelete="CASCADE"), nullable=False),
sa.Column("url", sa.Text, nullable=False),
sa.Column("url_hash", sa.String(40), unique=True, nullable=False, index=True),
sa.Column("guid", sa.String(255), index=True),
sa.Column("title", sa.Text, nullable=False),
sa.Column("body_html", sa.Text),
sa.Column("body_text", sa.Text, nullable=False, server_default=""),
sa.Column("lang_src", sa.String(8)),
sa.Column("author", sa.String(255)),
sa.Column("image_url", sa.Text),
sa.Column("title_zh", sa.Text),
sa.Column("body_zh_html", sa.Text),
sa.Column("body_zh_text", sa.Text),
sa.Column("summary_zh", sa.Text),
sa.Column("translation_status", sa.String(16), nullable=False, server_default="pending"),
sa.Column("translation_engine", sa.String(16)),
sa.Column("translation_chars", sa.Integer, nullable=False, server_default="0"),
sa.Column("translated_at", sa.DateTime(timezone=True)),
sa.Column("category", sa.String(32), index=True),
sa.Column("commentary", sa.Text),
sa.Column("entities", postgresql.JSONB),
sa.Column("sentiment", sa.Float),
sa.Column("topic_id", sa.String(64), index=True),
sa.Column("bias", sa.String(16)),
sa.Column("duplicate_of", sa.BigInteger, sa.ForeignKey("articles.id", ondelete="SET NULL")),
sa.Column("published_at", sa.DateTime(timezone=True), index=True),
sa.Column("fetched_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_articles_source_published", "articles", ["source_id", "published_at"])
op.create_index("ix_articles_status_published", "articles", ["translation_status", "published_at"])
# === 收藏 ===
op.create_table(
"bookmarks",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("article_id", sa.BigInteger, sa.ForeignKey("articles.id", ondelete="CASCADE"), nullable=False),
sa.Column("note", sa.Text),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("user_id", "article_id", name="uq_bookmark_user_article"),
)
op.create_index("ix_bookmarks_user_id", "bookmarks", ["user_id"])
op.create_index("ix_bookmarks_article_id", "bookmarks", ["article_id"])
# === 订阅 ===
subscription_match = postgresql.ENUM("any", "title", "body", name="subscription_match", create_type=True)
subscription_match.create(op.get_bind(), checkfirst=True)
op.create_table(
"subscriptions",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("keyword", sa.String(255), nullable=False),
sa.Column(
"match_in",
postgresql.ENUM("any", "title", "body", name="subscription_match", create_type=False),
nullable=False,
),
sa.Column("channel", sa.String(32), nullable=False, server_default="telegram"),
sa.Column("target", sa.Text),
sa.Column("enabled", sa.Boolean, nullable=False, server_default=sa.text("true")),
sa.Column("last_hit_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_subscriptions_user_id", "subscriptions", ["user_id"])
# === API Token ===
op.create_table(
"api_tokens",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("token_hash", sa.String(128), unique=True, nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True)),
sa.Column("expires_at", sa.DateTime(timezone=True)),
sa.Column("revoked_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"])
def downgrade() -> None:
op.drop_table("api_tokens")
op.drop_table("subscriptions")
op.drop_index("ix_subscriptions_user_id", table_name="subscriptions")
op.drop_table("bookmarks")
op.drop_index("ix_bookmarks_user_id", table_name="bookmarks")
op.drop_index("ix_bookmarks_article_id", table_name="bookmarks")
op.drop_index("ix_articles_status_published", table_name="articles")
op.drop_index("ix_articles_source_published", table_name="articles")
op.drop_table("articles")
op.drop_table("sources")
op.drop_table("users")
op.execute("DROP TYPE IF EXISTS subscription_match")
op.execute("DROP TYPE IF EXISTS source_kind")
op.execute("DROP TYPE IF EXISTS user_role")