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