"""数据模型(M0 范围:workspace / user / member / invitation / audit_log)。 M1 之后的任务、工单、知识表另起模块,但**继承同样的约定**: - 主键 uuid,应用侧生成(不依赖数据库扩展,方便迁移与分库) - 通用列 created_at / updated_at / version / deleted_at - 所有租户表必须有 workspace_id NOT NULL(AGENTS.md 第 2.5 条) """ from __future__ import annotations import uuid from datetime import datetime from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PgUUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass def new_uuid() -> uuid.UUID: return uuid.uuid4() class Timestamps: created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) version: Mapped[int] = mapped_column(nullable=False, default=1, server_default="1") class AppUser(Base, Timestamps): """全局用户,**不挂租户**(一个人可以属于多个工作区)。""" __tablename__ = "app_user" id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) phone: Mapped[str] = mapped_column(String(32), nullable=False, unique=True) nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="") avatar_url: Mapped[str | None] = mapped_column(String(512)) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") class Workspace(Base, Timestamps): """租户根。RLS 策略:workspace_id(id) = current_setting('app.ws_id')。""" __tablename__ = "workspace" id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) name: Mapped[str] = mapped_column(String(64), nullable=False) slug: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) plan: Mapped[str] = mapped_column(String(16), nullable=False, default="free") owner_id: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False ) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class Member(Base, Timestamps): """成员关系。RLS 策略:本租户可见 OR 自己是本人(用于"我加入了哪些工作区")。""" __tablename__ = "member" __table_args__ = ( UniqueConstraint("workspace_id", "user_id", name="uq_member_ws_user"), Index("ix_member_user", "user_id"), ) id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) workspace_id: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False ) user_id: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False ) role: Mapped[str] = mapped_column(String(16), nullable=False, default="member") status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") joined_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) class Invitation(Base, Timestamps): __tablename__ = "invitation" __table_args__ = ( UniqueConstraint("code", name="uq_invitation_code"), Index("ix_invitation_ws", "workspace_id"), ) id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) workspace_id: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False ) code: Mapped[str] = mapped_column(String(32), nullable=False) role: Mapped[str] = mapped_column(String(16), nullable=False, default="member") invited_by: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False ) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) accepted_by: Mapped[uuid.UUID | None] = mapped_column( PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL") ) class AuditLog(Base): """审计日志。**只增不改**;租户内可见,平台侧另有运维通道(M7)。""" __tablename__ = "audit_log" __table_args__ = (Index("ix_audit_ws_created", "workspace_id", "created_at"),) id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) workspace_id: Mapped[uuid.UUID | None] = mapped_column( PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="SET NULL") ) actor_user_id: Mapped[uuid.UUID | None] = mapped_column( PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL") ) action: Mapped[str] = mapped_column(String(64), nullable=False) target_type: Mapped[str] = mapped_column(String(32), nullable=False, default="") target_id: Mapped[str] = mapped_column(String(64), nullable=False, default="") payload: Mapped[dict | None] = mapped_column(JSONB) ip: Mapped[str] = mapped_column(String(64), nullable=False, default="") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) # 需要 RLS 的租户表(迁移与测试共用这一份清单,避免漏加) TENANT_TABLES: tuple[str, ...] = ("workspace", "member", "invitation", "audit_log")