feat(persistence): add WorkspaceMembershipRow ORM model

新建 backend/packages/harness/deerflow/persistence/workspace_membership/
{__init__.py, model.py},注册到 Base.metadata。

WorkspaceMembershipRow schema(来自 workspace-schema-design §2.2 锁定版):
  - workspace_id String(36) FK workspaces.id ON DELETE CASCADE — PK part
  - user_id String(36) FK users.id ON DELETE CASCADE — PK part
  - role String(16) NOT NULL(Stage 0 仅写 'owner',schema 允许 'admin'/'member'
    为 Stage 2 RBAC 准备)
  - invited_by String(36) FK users.id ON DELETE SET NULL nullable(Stage 2
    invitation 流程用)
  - joined_at DateTime(tz=True)

索引:
  - idx_workspace_memberships_user (user_id, workspace_id) — 倒查索引让
    /auth/me 列 user 所属 workspaces 走索引
  - idx_one_owner_per_workspace partial UNIQUE on workspace_id WHERE role='owner'
    —— 一 workspace 严格 1 个 owner;sqlite_where + postgresql_where 双驱动
    并存(实测两边都识别)

Stage 0 PR3 T3.6(提前到 T3.4-T3.5 之前;WorkspaceRepository.list_by_user
等会 JOIN 这张表)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1445043649
2026-05-12 21:09:19 +08:00
parent 8323bf68d2
commit d2d2d29c34
3 changed files with 89 additions and 1 deletions
@@ -9,6 +9,7 @@ The actual ORM classes have moved to entity-specific subpackages:
- ``deerflow.persistence.feedback``
- ``deerflow.persistence.user``
- ``deerflow.persistence.workspace`` (Stage 0 PR3)
- ``deerflow.persistence.workspace_membership`` (Stage 0 PR3)
``RunEventRow`` remains in ``deerflow.persistence.models.run_event`` because
its storage implementation lives in ``deerflow.runtime.events.store.db`` and
@@ -21,5 +22,14 @@ from deerflow.persistence.run.model import RunRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.workspace.model import WorkspaceRow
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
__all__ = ["FeedbackRow", "RunEventRow", "RunRow", "ThreadMetaRow", "UserRow", "WorkspaceRow"]
__all__ = [
"FeedbackRow",
"RunEventRow",
"RunRow",
"ThreadMetaRow",
"UserRow",
"WorkspaceMembershipRow",
"WorkspaceRow",
]
@@ -0,0 +1,16 @@
"""Workspace membership persistence — ORM model + repository.
Tracks who is a member of which workspace and what their role is
within that workspace. Composite primary key ``(workspace_id, user_id)``;
``role`` follows the three-state ``owner`` / ``admin`` / ``member`` model
(Stage 0 only writes ``'owner'``; Stage 2 RBAC rollout opens admin/member).
A workspace MUST have exactly one ``owner`` — enforced by a partial
unique index. Owner transfer is a two-row transactional swap.
"""
from __future__ import annotations
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
__all__ = ["WorkspaceMembershipRow"]
@@ -0,0 +1,62 @@
"""ORM model for workspace memberships (which user is in which workspace, with what role)."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class WorkspaceMembershipRow(Base):
__tablename__ = "workspace_memberships"
workspace_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("workspaces.id", ondelete="CASCADE"),
primary_key=True,
comment="所属 workspaceworkspace 删除时级联清掉成员记录",
)
user_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
comment="成员 user_id;用户删除时级联清掉成员记录",
)
role: Mapped[str] = mapped_column(
String(16),
nullable=False,
comment='角色字符串:Stage 0 仅写 "owner"Stage 2 RBAC 打开 "admin"/"member"。用 String(16) 而非 enum 以便未来加 "viewer"/"auditor" 不动 schema',
)
invited_by: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
comment="邀请人 user_idStage 2 invitation 流程才写);邀请人被删时此字段清空(不影响成员记录本身)",
)
joined_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
comment="加入 workspace 的时间(UTC",
)
__table_args__ = (
# 倒查索引:列出 user 所属的所有 workspace/auth/me 用)。
# 复合主键 (workspace_id, user_id) 的前导列是 workspace_id
# 所以"按 user_id 查"需要单独的索引。
Index("idx_workspace_memberships_user", "user_id", "workspace_id"),
# 一个 workspace 严格 1 个 owner —— partial unique on role='owner'。
# SQLite + Postgres 都支持 WHERE 子句的 partial unique;双驱动
# 维护两套等价 where 表达式。
Index(
"idx_one_owner_per_workspace",
"workspace_id",
unique=True,
sqlite_where=text("role = 'owner'"),
postgresql_where=text("role = 'owner'"),
),
{"comment": ("工作空间成员表(multi-tenant RBAC)。复合 PK (workspace_id, user_id);每个 workspace 必有恰好 1 个 ownerpartial unique 约束保证)。Stage 0 仅写 ownerStage 2 RBAC 打开 admin/member。")},
)