✨ feat: WP-12 协作空间——Workspace、RBAC、邀请与项目评审
- 迁移 13:workspace、workspace_member(四角色 + (workspace,member) 唯一)、workspace_invitation(四态流转)、project、project_version((project,version) 唯一、评审四态、提交/评审人留痕) - RBAC 三权分立:editor 提交版本、reviewer 批准/拒绝、owner 发布与成员管理——权限矩阵代码化(editor 不可自审、viewer 不可提交、发布前必须有 approved 当前版本) - 邀请流:owner 邀请(角色限定 editor/reviewer/viewer)→ 被邀者接受入成员;已是成员 409;移除所有者 422 - 评审流:版本号自动递增不可变;批准设为当前版本;拒绝不可逆(重审 409);未批准发布 422 - 隔离:非成员与被移除成员统一 404 fail-closed(require_member 单点) - 测试 82 项全绿(+3):完整协作流(邀请三角色→提交→批准→发布)、拒绝路径、成员移除失效与外人隔离
This commit is contained in:
1 parent
99985f5855
commit
795df0413e
7 files changed
+944
No files matched your search
@@ -965,3 +965,129 @@ class ModerationCase(Base):
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('pending', 'resolved')", name="status_values"),
|
||||
)
|
||||
|
||||
|
||||
class Workspace(Base):
|
||||
"""协作空间(WP-12):多人创作与权限边界。"""
|
||||
|
||||
__tablename__ = "workspace"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String, primary_key=True, default=lambda: new_id("workspace_")
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
owner_domain_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceMember(Base):
|
||||
"""成员与角色:编辑、评审、发布、财务权限分离(契约协作域)。"""
|
||||
|
||||
__tablename__ = "workspace_member"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("member_"))
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workspace.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
member_domain_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String, nullable=False, default="viewer")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"role IN ('owner', 'editor', 'reviewer', 'viewer')", name="role_values"
|
||||
),
|
||||
Index("uq_workspace_member", "workspace_id", "member_domain_id", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceInvitation(Base):
|
||||
"""邀请:接受后入成员;status 单向流转。"""
|
||||
|
||||
__tablename__ = "workspace_invitation"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String, primary_key=True, default=lambda: new_id("invitation_")
|
||||
)
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workspace.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
invitee_domain_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String, nullable=False, default="editor")
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"role IN ('editor', 'reviewer', 'viewer')", name="role_values"
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'declined', 'expired')", name="status_values"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""协作项目:条目由版本承载;发布需 owner 且版本已批准。"""
|
||||
|
||||
__tablename__ = "project"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("project_"))
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workspace.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String, nullable=False, default="generic")
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, default="draft")
|
||||
current_version_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("project_version.id", ondelete="set null", use_alter=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('draft', 'published')", name="status_values"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ProjectVersion(Base):
|
||||
"""项目版本:editor 提交 → reviewer 批准/拒绝;不可变演进。"""
|
||||
|
||||
__tablename__ = "project_version"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String, primary_key=True, default=lambda: new_id("project_version_")
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("project.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
version: Mapped[int] = mapped_column(nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, default="draft")
|
||||
submitted_by: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
reviewed_by: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('draft', 'submitted', 'approved', 'rejected')", name="status_values"
|
||||
),
|
||||
Index("uq_project_version_number", "project_id", "version", unique=True),
|
||||
)
|
||||
Reference in new issue
Block a user