- models: 删除 Profile 模型,字段合并到 User;User 新增云朵/公开/收藏计数器;Cloud 新增 favorite_count - schemas/serializers: 展平 UserOut/AuthOut/MeOut/AdminUserOut,移除嵌套 profile - deps: user.profile.role→user.role,移除 selectinload profile - auth/register: 注册时直接设置 user 字段,不再创建 Profile - clouds: 创建/删除云朵时同步 user.cloud_count 计数器,状态变化时同步 public_cloud_count - favorites: 点赞/取消时同步 cloud.favorite_count 计数器 - admin: 审批/隐藏/批量删时同步 public_cloud_count 计数器 - profiles/stats: 用计数器替代实时 COUNT 查询 - alembic: 新增 migration 合并 profiles 到 users,初始化 counters
228 lines
9.3 KiB
Python
228 lines
9.3 KiB
Python
import uuid
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
CheckConstraint,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
Numeric,
|
|
SmallInteger,
|
|
Text,
|
|
UniqueConstraint,
|
|
Uuid,
|
|
func,
|
|
text,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.database import Base
|
|
|
|
|
|
class TimestampMixin:
|
|
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 User(Base, TimestampMixin):
|
|
__tablename__ = "users"
|
|
__table_args__ = (
|
|
CheckConstraint("length(trim(username)) >= 2", name="ck_users_username_length"),
|
|
CheckConstraint("role IN ('user', 'admin')", name="ck_users_role"),
|
|
)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
email: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
|
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
username: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
avatar_path: Mapped[str | None] = mapped_column(Text)
|
|
role: Mapped[str] = mapped_column(Text, nullable=False, default="user", server_default="user")
|
|
is_disabled: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=False, server_default=text("false")
|
|
)
|
|
|
|
cloud_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
|
public_cloud_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
|
collection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
|
|
|
clouds: Mapped[list["Cloud"]] = relationship(back_populates="user", lazy="raise")
|
|
|
|
|
|
class RefreshSession(Base):
|
|
__tablename__ = "refresh_sessions"
|
|
__table_args__ = (
|
|
Index("ix_refresh_sessions_user_id", "user_id"),
|
|
Index("ix_refresh_sessions_replaced_by_id", "replaced_by_id"),
|
|
Index(
|
|
"ix_refresh_sessions_active_user",
|
|
"user_id",
|
|
"expires_at",
|
|
postgresql_where=text("revoked_at IS NULL"),
|
|
),
|
|
)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
replaced_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
Uuid, ForeignKey("refresh_sessions.id", ondelete="SET NULL")
|
|
)
|
|
user_agent: Mapped[str | None] = mapped_column(Text)
|
|
ip_address: Mapped[str | None] = mapped_column(Text)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
|
|
user: Mapped[User] = relationship(lazy="raise")
|
|
|
|
|
|
class EmailToken(Base):
|
|
__tablename__ = "email_tokens"
|
|
__table_args__ = (
|
|
CheckConstraint(
|
|
"purpose IN ('confirm_email', 'reset_password')", name="ck_email_tokens_purpose"
|
|
),
|
|
Index("ix_email_tokens_user_purpose", "user_id", "purpose"),
|
|
)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
purpose: Mapped[str] = mapped_column(Text, nullable=False)
|
|
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
|
|
|
|
class CloudType(Base):
|
|
__tablename__ = "cloud_types"
|
|
__table_args__ = (
|
|
CheckConstraint("rarity IN ('common', 'uncommon', 'rare')", name="ck_cloud_types_rarity"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(SmallInteger, primary_key=True, autoincrement=False)
|
|
name: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
name_en: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
genus: Mapped[str] = mapped_column(Text, nullable=False)
|
|
rarity: Mapped[str] = mapped_column(Text, nullable=False, default="common")
|
|
description: Mapped[str | None] = mapped_column(Text)
|
|
icon_url: Mapped[str | None] = mapped_column(Text)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
|
|
|
|
class Cloud(Base, TimestampMixin):
|
|
__tablename__ = "clouds"
|
|
__table_args__ = (
|
|
CheckConstraint("status IN ('pending', 'approved', 'rejected')", name="ck_clouds_status"),
|
|
CheckConstraint(
|
|
"((latitude IS NULL AND longitude IS NULL) OR "
|
|
"(latitude IS NOT NULL AND longitude IS NOT NULL))",
|
|
name="ck_clouds_coordinate_pair",
|
|
),
|
|
CheckConstraint("latitude IS NULL OR latitude BETWEEN -90 AND 90", name="ck_clouds_latitude"),
|
|
CheckConstraint(
|
|
"longitude IS NULL OR longitude BETWEEN -180 AND 180", name="ck_clouds_longitude"
|
|
),
|
|
CheckConstraint(
|
|
"((cloud_type_id IS NOT NULL AND custom_cloud_type IS NULL) OR "
|
|
"(cloud_type_id IS NULL AND custom_cloud_type IS NOT NULL "
|
|
"AND length(trim(custom_cloud_type)) > 0))",
|
|
name="ck_clouds_type_choice",
|
|
),
|
|
Index("ix_clouds_user_id", "user_id"),
|
|
Index("ix_clouds_cloud_type_id", "cloud_type_id"),
|
|
Index("ix_clouds_status_created", "status", text("created_at DESC")),
|
|
)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
cloud_type_id: Mapped[int | None] = mapped_column(
|
|
SmallInteger, ForeignKey("cloud_types.id", ondelete="SET NULL")
|
|
)
|
|
custom_cloud_type: Mapped[str | None] = mapped_column(Text)
|
|
image_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
thumbnail_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
latitude: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
|
|
longitude: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
|
|
location_name: Mapped[str | None] = mapped_column(Text)
|
|
description: Mapped[str | None] = mapped_column(Text)
|
|
captured_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
status: Mapped[str] = mapped_column(Text, nullable=False, default="pending", server_default="pending")
|
|
is_hidden: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=False, server_default=text("false")
|
|
)
|
|
favorite_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
|
|
|
user: Mapped[User] = relationship(back_populates="clouds", lazy="raise")
|
|
cloud_type: Mapped[CloudType | None] = relationship(lazy="raise")
|
|
|
|
|
|
class UserCollection(Base):
|
|
__tablename__ = "user_collections"
|
|
__table_args__ = (
|
|
UniqueConstraint("user_id", "cloud_type_id", name="uq_user_collections_user_type"),
|
|
Index("ix_user_collections_user_id", "user_id"),
|
|
Index("ix_user_collections_cloud_type_id", "cloud_type_id"),
|
|
Index("ix_user_collections_first_cloud_id", "first_cloud_id"),
|
|
)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
cloud_type_id: Mapped[int] = mapped_column(
|
|
SmallInteger, ForeignKey("cloud_types.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
first_cloud_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
Uuid, ForeignKey("clouds.id", ondelete="SET NULL")
|
|
)
|
|
unlocked_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
|
|
cloud_type: Mapped[CloudType] = relationship(lazy="raise")
|
|
first_cloud: Mapped[Cloud | None] = relationship(lazy="raise")
|
|
|
|
|
|
class CloudFavorite(Base):
|
|
__tablename__ = "cloud_favorites"
|
|
__table_args__ = (
|
|
UniqueConstraint("user_id", "cloud_id", name="uq_cloud_favorites_user_cloud"),
|
|
Index("ix_cloud_favorites_user_id", "user_id"),
|
|
Index("ix_cloud_favorites_cloud_id", "cloud_id"),
|
|
)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
cloud_id: Mapped[uuid.UUID] = mapped_column(
|
|
Uuid, ForeignKey("clouds.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
|
|
cloud: Mapped[Cloud] = relationship(lazy="raise")
|