✨ 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
@@ -0,0 +1,207 @@
|
||||
"""协作空间:成员、邀请、项目与评审
|
||||
|
||||
Revision ID: bd36c0a63816
|
||||
Revises: b7f1a0032451
|
||||
Create Date: 2026-09-12 09:31:43.277430
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'bd36c0a63816'
|
||||
down_revision: str | Sequence[str] | None = 'b7f1a0032451'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('follow',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('follower_domain_id', sa.String(), nullable=False),
|
||||
sa.Column('followee_domain_id', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['followee_domain_id'], ['personal_domain.id'], name='fk_follow_followee_domain', ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['follower_domain_id'], ['personal_domain.id'], name='fk_follow_follower_domain', ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_follow'))
|
||||
)
|
||||
op.create_index(op.f('ix_follow_followee_domain_id'), 'follow', ['followee_domain_id'], unique=False)
|
||||
op.create_index(op.f('ix_follow_follower_domain_id'), 'follow', ['follower_domain_id'], unique=False)
|
||||
op.create_index('uq_follow_pair', 'follow', ['follower_domain_id', 'followee_domain_id'], unique=True)
|
||||
op.create_table('report',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('reporter_domain_id', sa.String(), nullable=False),
|
||||
sa.Column('target_type', sa.String(), nullable=False),
|
||||
sa.Column('target_id', sa.String(), nullable=False),
|
||||
sa.Column('reason', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("status IN ('pending', 'reviewed', 'actioned')", name=op.f('ck_report_status_values')),
|
||||
sa.CheckConstraint("target_type IN ('content', 'comment')", name=op.f('ck_report_target_type_values')),
|
||||
sa.ForeignKeyConstraint(['reporter_domain_id'], ['personal_domain.id'], name=op.f('fk_report_personal_domain'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_report'))
|
||||
)
|
||||
op.create_index(op.f('ix_report_reporter_domain_id'), 'report', ['reporter_domain_id'], unique=False)
|
||||
op.create_table('workspace',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('owner_domain_id', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['owner_domain_id'], ['personal_domain.id'], name=op.f('fk_workspace_personal_domain'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_workspace'))
|
||||
)
|
||||
op.create_index(op.f('ix_workspace_owner_domain_id'), 'workspace', ['owner_domain_id'], unique=False)
|
||||
op.create_table('community_content',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('author_domain_id', sa.String(), nullable=False),
|
||||
sa.Column('kind', sa.String(), nullable=False),
|
||||
sa.Column('title', sa.String(), nullable=False),
|
||||
sa.Column('body', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('character_id', sa.String(), nullable=True),
|
||||
sa.Column('visibility', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("kind IN ('post', 'character_release')", name=op.f('ck_community_content_kind_values')),
|
||||
sa.CheckConstraint("status IN ('draft', 'submitted', 'published', 'limited', 'suspended', 'removed')", name=op.f('ck_community_content_status_values')),
|
||||
sa.CheckConstraint("visibility IN ('public', 'followers')", name=op.f('ck_community_content_visibility_values')),
|
||||
sa.ForeignKeyConstraint(['author_domain_id'], ['personal_domain.id'], name=op.f('fk_community_content_personal_domain'), ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_community_content_character'), ondelete='set null'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_community_content'))
|
||||
)
|
||||
op.create_index(op.f('ix_community_content_author_domain_id'), 'community_content', ['author_domain_id'], unique=False)
|
||||
op.create_table('moderation_case',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('report_id', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('action_taken', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("status IN ('pending', 'resolved')", name=op.f('ck_moderation_case_status_values')),
|
||||
sa.ForeignKeyConstraint(['report_id'], ['report.id'], name=op.f('fk_moderation_case_report'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_moderation_case'))
|
||||
)
|
||||
op.create_index(op.f('ix_moderation_case_report_id'), 'moderation_case', ['report_id'], unique=False)
|
||||
op.create_table('project',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('kind', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('current_version_id', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("status IN ('draft', 'published')", name=op.f('ck_project_status_values')),
|
||||
sa.ForeignKeyConstraint(['current_version_id'], ['project_version.id'], name=op.f('fk_project_project_version'), ondelete='set null', use_alter=True),
|
||||
sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], name=op.f('fk_project_workspace'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_project'))
|
||||
)
|
||||
op.create_index(op.f('ix_project_workspace_id'), 'project', ['workspace_id'], unique=False)
|
||||
op.create_table('workspace_invitation',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(), nullable=False),
|
||||
sa.Column('invitee_domain_id', sa.String(), nullable=False),
|
||||
sa.Column('role', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("role IN ('editor', 'reviewer', 'viewer')", name=op.f('ck_workspace_invitation_role_values')),
|
||||
sa.CheckConstraint("status IN ('pending', 'accepted', 'declined', 'expired')", name=op.f('ck_workspace_invitation_status_values')),
|
||||
sa.ForeignKeyConstraint(['invitee_domain_id'], ['personal_domain.id'], name=op.f('fk_workspace_invitation_personal_domain'), ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], name=op.f('fk_workspace_invitation_workspace'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_workspace_invitation'))
|
||||
)
|
||||
op.create_index(op.f('ix_workspace_invitation_workspace_id'), 'workspace_invitation', ['workspace_id'], unique=False)
|
||||
op.create_table('workspace_member',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(), nullable=False),
|
||||
sa.Column('member_domain_id', sa.String(), nullable=False),
|
||||
sa.Column('role', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("role IN ('owner', 'editor', 'reviewer', 'viewer')", name=op.f('ck_workspace_member_role_values')),
|
||||
sa.ForeignKeyConstraint(['member_domain_id'], ['personal_domain.id'], name=op.f('fk_workspace_member_personal_domain'), ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], name=op.f('fk_workspace_member_workspace'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_workspace_member'))
|
||||
)
|
||||
op.create_index(op.f('ix_workspace_member_workspace_id'), 'workspace_member', ['workspace_id'], unique=False)
|
||||
op.create_index('uq_workspace_member', 'workspace_member', ['workspace_id', 'member_domain_id'], unique=True)
|
||||
op.create_table('comment',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('content_id', sa.String(), nullable=False),
|
||||
sa.Column('author_domain_id', sa.String(), nullable=False),
|
||||
sa.Column('parent_comment_id', sa.String(), nullable=True),
|
||||
sa.Column('body', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("status IN ('visible', 'removed')", name=op.f('ck_comment_status_values')),
|
||||
sa.ForeignKeyConstraint(['author_domain_id'], ['personal_domain.id'], name=op.f('fk_comment_personal_domain'), ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['content_id'], ['community_content.id'], name=op.f('fk_comment_community_content'), ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['parent_comment_id'], ['comment.id'], name=op.f('fk_comment_comment'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_comment'))
|
||||
)
|
||||
op.create_index(op.f('ix_comment_content_id'), 'comment', ['content_id'], unique=False)
|
||||
op.create_table('project_version',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('project_id', sa.String(), nullable=False),
|
||||
sa.Column('version', sa.Integer(), nullable=False),
|
||||
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('submitted_by', sa.String(), nullable=True),
|
||||
sa.Column('reviewed_by', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("status IN ('draft', 'submitted', 'approved', 'rejected')", name=op.f('ck_project_version_status_values')),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['project.id'], name=op.f('fk_project_version_project'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_project_version'))
|
||||
)
|
||||
op.create_index(op.f('ix_project_version_project_id'), 'project_version', ['project_id'], unique=False)
|
||||
op.create_index('uq_project_version_number', 'project_version', ['project_id', 'version'], unique=True)
|
||||
op.create_table('reaction',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('content_id', sa.String(), nullable=False),
|
||||
sa.Column('actor_domain_id', sa.String(), nullable=False),
|
||||
sa.Column('kind', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("kind IN ('like', 'save')", name=op.f('ck_reaction_kind_values')),
|
||||
sa.ForeignKeyConstraint(['actor_domain_id'], ['personal_domain.id'], name=op.f('fk_reaction_personal_domain'), ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['content_id'], ['community_content.id'], name=op.f('fk_reaction_community_content'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_reaction'))
|
||||
)
|
||||
op.create_index(op.f('ix_reaction_content_id'), 'reaction', ['content_id'], unique=False)
|
||||
op.create_index('uq_reaction_content_actor_kind', 'reaction', ['content_id', 'actor_domain_id', 'kind'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('uq_reaction_content_actor_kind', table_name='reaction')
|
||||
op.drop_index(op.f('ix_reaction_content_id'), table_name='reaction')
|
||||
op.drop_table('reaction')
|
||||
op.drop_index('uq_project_version_number', table_name='project_version')
|
||||
op.drop_index(op.f('ix_project_version_project_id'), table_name='project_version')
|
||||
op.drop_table('project_version')
|
||||
op.drop_index(op.f('ix_comment_content_id'), table_name='comment')
|
||||
op.drop_table('comment')
|
||||
op.drop_index('uq_workspace_member', table_name='workspace_member')
|
||||
op.drop_index(op.f('ix_workspace_member_workspace_id'), table_name='workspace_member')
|
||||
op.drop_table('workspace_member')
|
||||
op.drop_index(op.f('ix_workspace_invitation_workspace_id'), table_name='workspace_invitation')
|
||||
op.drop_table('workspace_invitation')
|
||||
op.drop_index(op.f('ix_project_workspace_id'), table_name='project')
|
||||
op.drop_table('project')
|
||||
op.drop_index(op.f('ix_moderation_case_report_id'), table_name='moderation_case')
|
||||
op.drop_table('moderation_case')
|
||||
op.drop_index(op.f('ix_community_content_author_domain_id'), table_name='community_content')
|
||||
op.drop_table('community_content')
|
||||
op.drop_index(op.f('ix_workspace_owner_domain_id'), table_name='workspace')
|
||||
op.drop_table('workspace')
|
||||
op.drop_index(op.f('ix_report_reporter_domain_id'), table_name='report')
|
||||
op.drop_table('report')
|
||||
op.drop_index('uq_follow_pair', table_name='follow')
|
||||
op.drop_index(op.f('ix_follow_follower_domain_id'), table_name='follow')
|
||||
op.drop_index(op.f('ix_follow_followee_domain_id'), table_name='follow')
|
||||
op.drop_table('follow')
|
||||
# ### end Alembic commands ###
|
||||
Reference in new issue
Block a user