✨ 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:
chenxin committed 2026-09-12 09:33:29 +08:00
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 ###