✨ 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 ###
|
||||
@@ -16,6 +16,7 @@ from app.marketplace.routes import router as marketplace_router
|
||||
from app.memories.routes import cycle_router
|
||||
from app.memories.routes import router as memories_router
|
||||
from app.tools.routes import calls_router, tools_router
|
||||
from app.workspaces.routes import router as workspaces_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -44,6 +45,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(finance_router)
|
||||
app.include_router(webhook_router)
|
||||
app.include_router(community_router)
|
||||
app.include_router(workspaces_router)
|
||||
|
||||
@app.get("/healthz", tags=["ops"])
|
||||
async def healthz() -> dict[str, str]:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""协作域:Workspace、成员、邀请与项目评审。"""
|
||||
@@ -0,0 +1,181 @@
|
||||
"""协作域路由(WP-12)。"""
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from app.auth.dependencies import get_current_user, require_state_change
|
||||
from app.conversations.routes import _domain_of
|
||||
from app.db.session import DbSession
|
||||
from app.models import PersonalDomain, User
|
||||
from app.workspaces import service
|
||||
|
||||
router = APIRouter(prefix="/v1/workspaces", tags=["workspaces"])
|
||||
|
||||
|
||||
def _member_out(m: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"id": m.id,
|
||||
"workspace_id": m.workspace_id,
|
||||
"member_domain_id": m.member_domain_id,
|
||||
"role": m.role,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _project_out(p: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"id": p.id,
|
||||
"workspace_id": p.workspace_id,
|
||||
"name": p.name,
|
||||
"status": p.status,
|
||||
"current_version_id": p.current_version_id,
|
||||
"created_at": p.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _version_out(v: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"id": v.id,
|
||||
"project_id": v.project_id,
|
||||
"version": v.version,
|
||||
"payload": v.payload,
|
||||
"status": v.status,
|
||||
"submitted_by": v.submitted_by,
|
||||
"reviewed_by": v.reviewed_by,
|
||||
"created_at": v.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_workspace(
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
body: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
ws = await service.create_workspace(
|
||||
db, domain.id, body.get("name", ""), getattr(request.state, "trace_id", None)
|
||||
)
|
||||
return {"id": ws.id, "name": ws.name, "owner_domain_id": ws.owner_domain_id}
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/invitations", status_code=201)
|
||||
async def invite(
|
||||
workspace_id: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
body: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
invitee = await db.get(PersonalDomain, body.get("invitee_domain_id", ""))
|
||||
if invitee is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "被邀用户不存在")
|
||||
invitation = await service.invite(
|
||||
db, domain.id, workspace_id, invitee.id, body.get("role", "editor"),
|
||||
getattr(request.state, "trace_id", None),
|
||||
)
|
||||
return {"id": invitation.id, "status": invitation.status, "role": invitation.role}
|
||||
|
||||
|
||||
@router.post("/invitations/{invitation_id}/acceptances", status_code=201)
|
||||
async def accept(
|
||||
invitation_id: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
member = await service.accept_invitation(
|
||||
db, domain.id, invitation_id, getattr(request.state, "trace_id", None)
|
||||
)
|
||||
return _member_out(member)
|
||||
|
||||
|
||||
@router.get("/{workspace_id}/members")
|
||||
async def list_members(
|
||||
workspace_id: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
rows = await service.list_members(db, domain.id, workspace_id)
|
||||
return {"data": [_member_out(m) for m in rows]}
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}/members/{member_id}", status_code=204)
|
||||
async def remove_member(
|
||||
workspace_id: str,
|
||||
member_id: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
) -> None:
|
||||
domain = await _domain_of(db, user)
|
||||
await service.remove_member(
|
||||
db, domain.id, workspace_id, member_id, getattr(request.state, "trace_id", None)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/projects", status_code=201)
|
||||
async def create_project(
|
||||
workspace_id: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
body: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
project = await service.create_project(
|
||||
db, domain.id, workspace_id, body.get("name", ""),
|
||||
getattr(request.state, "trace_id", None),
|
||||
)
|
||||
return _project_out(project)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/versions", status_code=201)
|
||||
async def submit_version(
|
||||
project_id: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
version = await service.submit_version(
|
||||
db, domain.id, project_id, body.get("payload", {}),
|
||||
getattr(request.state, "trace_id", None),
|
||||
)
|
||||
return _version_out(version)
|
||||
|
||||
|
||||
@router.post("/project-versions/{version_id}/reviews", status_code=201)
|
||||
async def review_version(
|
||||
version_id: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
body: dict[str, bool],
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
version = await service.review_version(
|
||||
db, domain.id, version_id, bool(body.get("approve")),
|
||||
getattr(request.state, "trace_id", None),
|
||||
)
|
||||
return _version_out(version)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/publications", status_code=200)
|
||||
async def publish(
|
||||
project_id: str,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(require_state_change)],
|
||||
request: Request,
|
||||
) -> dict[str, Any]:
|
||||
domain = await _domain_of(db, user)
|
||||
project = await service.publish_project(
|
||||
db, domain.id, project_id, getattr(request.state, "trace_id", None)
|
||||
)
|
||||
return _project_out(project)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""协作域:Workspace、成员 RBAC、邀请与项目评审(WP-12)。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import (
|
||||
AuditEvent,
|
||||
Project,
|
||||
ProjectVersion,
|
||||
Workspace,
|
||||
WorkspaceInvitation,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
# 角色能力矩阵:编辑(提交版本)、评审(批准/拒绝)、发布(owner)
|
||||
ROLE_CAN_SUBMIT = {"owner", "editor"}
|
||||
ROLE_CAN_REVIEW = {"owner", "reviewer"}
|
||||
|
||||
|
||||
async def require_member(
|
||||
db: AsyncSession, domain_id: str, workspace_id: str, roles: set[str] | None = None
|
||||
) -> WorkspaceMember:
|
||||
"""成员校验:非成员或角色不足一律 404/403(跨 Workspace fail-closed)。"""
|
||||
member = (
|
||||
await db.execute(
|
||||
select(WorkspaceMember).where(
|
||||
WorkspaceMember.workspace_id == workspace_id,
|
||||
WorkspaceMember.member_domain_id == domain_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if member is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "工作区不存在")
|
||||
if roles is not None and member.role not in roles:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, f"角色 {member.role} 无权执行此操作")
|
||||
return member
|
||||
|
||||
|
||||
async def create_workspace(
|
||||
db: AsyncSession, domain_id: str, name: str, trace_id: str | None
|
||||
) -> Workspace:
|
||||
if not name.strip():
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "name 不能为空")
|
||||
ws = Workspace(name=name, owner_domain_id=domain_id)
|
||||
db.add(ws)
|
||||
await db.flush()
|
||||
db.add(
|
||||
WorkspaceMember(workspace_id=ws.id, member_domain_id=domain_id, role="owner")
|
||||
)
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=domain_id, action="workspace.created",
|
||||
aggregate_type="workspace", aggregate_id=ws.id, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return ws
|
||||
|
||||
|
||||
async def invite(
|
||||
db: AsyncSession,
|
||||
owner_domain_id: str,
|
||||
workspace_id: str,
|
||||
invitee_domain_id: str,
|
||||
role: str,
|
||||
trace_id: str | None,
|
||||
) -> WorkspaceInvitation:
|
||||
await require_member(db, owner_domain_id, workspace_id, {"owner"})
|
||||
if role not in {"editor", "reviewer", "viewer"}:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "role 取值不合法")
|
||||
existing_member = (
|
||||
await db.execute(
|
||||
select(WorkspaceMember).where(
|
||||
WorkspaceMember.workspace_id == workspace_id,
|
||||
WorkspaceMember.member_domain_id == invitee_domain_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing_member is not None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "已是成员")
|
||||
invitation = WorkspaceInvitation(
|
||||
workspace_id=workspace_id, invitee_domain_id=invitee_domain_id, role=role
|
||||
)
|
||||
db.add(invitation)
|
||||
await db.flush()
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=owner_domain_id, action="workspace.invited",
|
||||
aggregate_type="workspace", aggregate_id=workspace_id,
|
||||
payload={"invitee": invitee_domain_id, "role": role}, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return invitation
|
||||
|
||||
|
||||
async def accept_invitation(
|
||||
db: AsyncSession, domain_id: str, invitation_id: str, trace_id: str | None
|
||||
) -> WorkspaceMember:
|
||||
invitation = await db.get(WorkspaceInvitation, invitation_id)
|
||||
if invitation is None or invitation.invitee_domain_id != domain_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "邀请不存在")
|
||||
if invitation.status != "pending":
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "邀请已处理")
|
||||
invitation.status = "accepted"
|
||||
member = WorkspaceMember(
|
||||
workspace_id=invitation.workspace_id,
|
||||
member_domain_id=domain_id,
|
||||
role=invitation.role,
|
||||
)
|
||||
db.add(member)
|
||||
await db.flush()
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=domain_id, action="workspace.joined",
|
||||
aggregate_type="workspace", aggregate_id=invitation.workspace_id, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return member
|
||||
|
||||
|
||||
async def remove_member(
|
||||
db: AsyncSession, owner_domain_id: str, workspace_id: str, member_id: str,
|
||||
trace_id: str | None,
|
||||
) -> None:
|
||||
await require_member(db, owner_domain_id, workspace_id, {"owner"})
|
||||
member = await db.get(WorkspaceMember, member_id)
|
||||
if member is None or member.workspace_id != workspace_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
|
||||
if member.role == "owner":
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "不能移除所有者")
|
||||
await db.delete(member)
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=owner_domain_id, action="workspace.member_removed",
|
||||
aggregate_type="workspace", aggregate_id=workspace_id,
|
||||
payload={"removed": member.member_domain_id}, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def list_members(
|
||||
db: AsyncSession, domain_id: str, workspace_id: str
|
||||
) -> list[WorkspaceMember]:
|
||||
await require_member(db, domain_id, workspace_id)
|
||||
return list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(WorkspaceMember)
|
||||
.where(WorkspaceMember.workspace_id == workspace_id)
|
||||
.order_by(WorkspaceMember.created_at.asc())
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
async def create_project(
|
||||
db: AsyncSession, domain_id: str, workspace_id: str, name: str, trace_id: str | None
|
||||
) -> Project:
|
||||
await require_member(db, domain_id, workspace_id, ROLE_CAN_SUBMIT)
|
||||
if not name.strip():
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "name 不能为空")
|
||||
project = Project(workspace_id=workspace_id, name=name)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=domain_id, action="project.created",
|
||||
aggregate_type="project", aggregate_id=project.id, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return project
|
||||
|
||||
|
||||
async def require_project(
|
||||
db: AsyncSession, domain_id: str, project_id: str
|
||||
) -> Project:
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "项目不存在")
|
||||
await require_member(db, domain_id, project.workspace_id)
|
||||
return project
|
||||
|
||||
|
||||
async def submit_version(
|
||||
db: AsyncSession, domain_id: str, project_id: str, payload: dict[str, Any],
|
||||
trace_id: str | None,
|
||||
) -> ProjectVersion:
|
||||
"""editor 提交新版本:版本号自动递增,submitted 状态等待评审。"""
|
||||
project = await require_project(db, domain_id, project_id)
|
||||
await require_member(db, domain_id, project.workspace_id, ROLE_CAN_SUBMIT)
|
||||
last = (
|
||||
await db.execute(
|
||||
select(ProjectVersion.version)
|
||||
.where(ProjectVersion.project_id == project.id)
|
||||
.order_by(ProjectVersion.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
version = ProjectVersion(
|
||||
project_id=project.id,
|
||||
version=(last + 1) if last is not None else 1,
|
||||
payload=payload,
|
||||
status="submitted",
|
||||
submitted_by=domain_id,
|
||||
)
|
||||
db.add(version)
|
||||
await db.flush()
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=domain_id, action="project.version_submitted",
|
||||
aggregate_type="project", aggregate_id=project.id,
|
||||
payload={"version": version.version}, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return version
|
||||
|
||||
|
||||
async def review_version(
|
||||
db: AsyncSession, domain_id: str, version_id: str, approve: bool, trace_id: str | None
|
||||
) -> ProjectVersion:
|
||||
"""reviewer 批准/拒绝:批准即将版本设为项目当前版本。"""
|
||||
version = await db.get(ProjectVersion, version_id)
|
||||
if version is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在")
|
||||
project = await db.get(Project, version.project_id)
|
||||
assert project is not None
|
||||
await require_member(db, domain_id, project.workspace_id, ROLE_CAN_REVIEW)
|
||||
if version.status != "submitted":
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "版本不在待评审状态")
|
||||
version.status = "approved" if approve else "rejected"
|
||||
version.reviewed_by = domain_id
|
||||
if approve:
|
||||
project.current_version_id = version.id
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=domain_id,
|
||||
action="project.version_approved" if approve else "project.version_rejected",
|
||||
aggregate_type="project", aggregate_id=project.id,
|
||||
payload={"version": version.version}, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return version
|
||||
|
||||
|
||||
async def publish_project(
|
||||
db: AsyncSession, domain_id: str, project_id: str, trace_id: str | None
|
||||
) -> Project:
|
||||
"""发布:仅 owner,且必须有已批准的当前版本(编辑/评审/发布三权分离)。"""
|
||||
project = await require_project(db, domain_id, project_id)
|
||||
await require_member(db, domain_id, project.workspace_id, {"owner"})
|
||||
if project.current_version_id is None:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "没有已批准版本,不可发布")
|
||||
if project.status == "published":
|
||||
return project # 幂等
|
||||
project.status = "published"
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=domain_id, action="project.published",
|
||||
aggregate_type="project", aggregate_id=project.id, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return project
|
||||
@@ -1282,3 +1282,158 @@ class TestCommunity:
|
||||
b = await login(client, "cm7b@example.com")
|
||||
resp = await client.post(f"/v1/community/contents/{cid}/withdrawals", headers=b)
|
||||
assert resp.status_code == 404 # 非作者:fail-closed
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client")
|
||||
class TestWorkspaces:
|
||||
async def _domain_id_of(self, client, headers) -> str:
|
||||
"""经发布列表拿自己的域 ID(稳定取法)。"""
|
||||
resp = await client.post(
|
||||
"/v1/community/contents",
|
||||
json={"kind": "post", "title": f"定位-{headers['Cookie'][-8:]}"},
|
||||
headers=headers,
|
||||
)
|
||||
cid = resp.json()["id"]
|
||||
listing = await client.get("/v1/community/contents", headers=headers)
|
||||
return next(
|
||||
c["author_domain_id"] for c in listing.json()["data"] if c["id"] == cid
|
||||
)
|
||||
|
||||
async def test_full_collaboration_flow(self, client) -> None:
|
||||
owner = await login(client, "ws-owner@example.com")
|
||||
editor = await login(client, "ws-editor@example.com")
|
||||
reviewer = await login(client, "ws-reviewer@example.com")
|
||||
viewer = await login(client, "ws-viewer@example.com")
|
||||
ws = await client.post(
|
||||
"/v1/workspaces", json={"name": "同契台"}, headers=owner
|
||||
)
|
||||
assert ws.status_code == 201, ws.text
|
||||
ws_id = ws.json()["id"]
|
||||
# 邀请三角色
|
||||
invs = {}
|
||||
for name, hdrs, role in [
|
||||
("editor", editor, "editor"), ("reviewer", reviewer, "reviewer"),
|
||||
("viewer", viewer, "viewer"),
|
||||
]:
|
||||
did = await self._domain_id_of(client, hdrs)
|
||||
inv = await client.post(
|
||||
f"/v1/workspaces/{ws_id}/invitations",
|
||||
json={"invitee_domain_id": did, "role": role}, headers=owner,
|
||||
)
|
||||
assert inv.status_code == 201, inv.text
|
||||
acc = await client.post(
|
||||
f"/v1/workspaces/invitations/{inv.json()['id']}/acceptances", headers=hdrs
|
||||
)
|
||||
assert acc.status_code == 201 and acc.json()["role"] == role
|
||||
invs[name] = acc.json()
|
||||
members = await client.get(f"/v1/workspaces/{ws_id}/members", headers=owner)
|
||||
assert len(members.json()["data"]) == 4
|
||||
|
||||
# 项目与版本评审:editor 提交 → reviewer 批准 → owner 发布
|
||||
project = await client.post(
|
||||
f"/v1/workspaces/{ws_id}/projects", json={"name": "世界观设定"}, headers=editor
|
||||
)
|
||||
assert project.status_code == 201
|
||||
pid = project.json()["id"]
|
||||
v1 = await client.post(
|
||||
f"/v1/workspaces/projects/{pid}/versions",
|
||||
json={"payload": {"text": "第一版"}}, headers=editor,
|
||||
)
|
||||
assert v1.status_code == 201 and v1.json()["version"] == 1
|
||||
assert v1.json()["status"] == "submitted"
|
||||
# viewer 不能提交
|
||||
denied = await client.post(
|
||||
f"/v1/workspaces/projects/{pid}/versions",
|
||||
json={"payload": {"text": "越权"}}, headers=viewer,
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
# editor 不能评审(三权分立)
|
||||
self_review = await client.post(
|
||||
f"/v1/workspaces/project-versions/{v1.json()['id']}/reviews",
|
||||
json={"approve": True}, headers=editor,
|
||||
)
|
||||
assert self_review.status_code == 403
|
||||
# 未批准前 owner 不能发布
|
||||
early = await client.post(
|
||||
f"/v1/workspaces/projects/{pid}/publications", headers=owner
|
||||
)
|
||||
assert early.status_code == 422
|
||||
# reviewer 批准 → 设为当前版本
|
||||
approved = await client.post(
|
||||
f"/v1/workspaces/project-versions/{v1.json()['id']}/reviews",
|
||||
json={"approve": True}, headers=reviewer,
|
||||
)
|
||||
assert approved.json()["status"] == "approved"
|
||||
# owner 发布
|
||||
published = await client.post(
|
||||
f"/v1/workspaces/projects/{pid}/publications", headers=owner
|
||||
)
|
||||
assert published.json()["status"] == "published"
|
||||
|
||||
async def test_rejection_path(self, client) -> None:
|
||||
owner = await login(client, "ws2-owner@example.com")
|
||||
editor = await login(client, "ws2-editor@example.com")
|
||||
reviewer = await login(client, "ws2-reviewer@example.com")
|
||||
ws = await client.post("/v1/workspaces", json={"name": "拒绝路径"}, headers=owner)
|
||||
ws_id = ws.json()["id"]
|
||||
for hdrs, role in [(editor, "editor"), (reviewer, "reviewer")]:
|
||||
did = await self._domain_id_of(client, hdrs)
|
||||
inv = await client.post(
|
||||
f"/v1/workspaces/{ws_id}/invitations",
|
||||
json={"invitee_domain_id": did, "role": role}, headers=owner,
|
||||
)
|
||||
await client.post(
|
||||
f"/v1/workspaces/invitations/{inv.json()['id']}/acceptances", headers=hdrs
|
||||
)
|
||||
project = await client.post(
|
||||
f"/v1/workspaces/{ws_id}/projects", json={"name": "P"}, headers=editor
|
||||
)
|
||||
pid = project.json()["id"]
|
||||
v1 = await client.post(
|
||||
f"/v1/workspaces/projects/{pid}/versions",
|
||||
json={"payload": {"text": "v1"}}, headers=editor,
|
||||
)
|
||||
rejected = await client.post(
|
||||
f"/v1/workspaces/project-versions/{v1.json()['id']}/reviews",
|
||||
json={"approve": False}, headers=reviewer,
|
||||
)
|
||||
assert rejected.json()["status"] == "rejected"
|
||||
# 拒绝后重审 409
|
||||
again = await client.post(
|
||||
f"/v1/workspaces/project-versions/{v1.json()['id']}/reviews",
|
||||
json={"approve": True}, headers=reviewer,
|
||||
)
|
||||
assert again.status_code == 409
|
||||
# 被拒版本不能作为当前版本发布
|
||||
pub = await client.post(
|
||||
f"/v1/workspaces/projects/{pid}/publications", headers=owner
|
||||
)
|
||||
assert pub.status_code == 422
|
||||
|
||||
async def test_member_removal_and_isolation(self, client) -> None:
|
||||
owner = await login(client, "ws3-owner@example.com")
|
||||
editor = await login(client, "ws3-editor@example.com")
|
||||
outsider = await login(client, "ws3-outsider@example.com")
|
||||
ws = await client.post("/v1/workspaces", json={"name": "隔离"}, headers=owner)
|
||||
ws_id = ws.json()["id"]
|
||||
did = await self._domain_id_of(client, editor)
|
||||
inv = await client.post(
|
||||
f"/v1/workspaces/{ws_id}/invitations",
|
||||
json={"invitee_domain_id": did, "role": "editor"}, headers=owner,
|
||||
)
|
||||
member = await client.post(
|
||||
f"/v1/workspaces/invitations/{inv.json()['id']}/acceptances", headers=editor
|
||||
)
|
||||
# 外人访问 404
|
||||
leak = await client.get(f"/v1/workspaces/{ws_id}/members", headers=outsider)
|
||||
assert leak.status_code == 404
|
||||
# owner 移除成员
|
||||
removed = await client.request(
|
||||
"DELETE",
|
||||
f"/v1/workspaces/{ws_id}/members/{member.json()['id']}",
|
||||
headers=owner,
|
||||
)
|
||||
assert removed.status_code == 204
|
||||
# 被移除后访问失效
|
||||
after = await client.get(f"/v1/workspaces/{ws_id}/members", headers=editor)
|
||||
assert after.status_code == 404
|
||||
Reference in new issue
Block a user