feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档
- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件 - alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT - tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行) - web: React18+Vite 骨架(登录、工作区、邀请落地页) - android: Compose 骨架(登录、工作区列表) - 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""数据模型(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")
|
||||
Reference in New Issue
Block a user