From 795df0413eb0c7486a7620ce2f4d21de18d79e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Sat, 12 Sep 2026 09:33:29 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20WP-12=20=E5=8D=8F=E4=BD=9C?= =?UTF-8?q?=E7=A9=BA=E9=97=B4=E2=80=94=E2=80=94Workspace=E3=80=81RBAC?= =?UTF-8?q?=E3=80=81=E9=82=80=E8=AF=B7=E4=B8=8E=E9=A1=B9=E7=9B=AE=E8=AF=84?= =?UTF-8?q?=E5=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 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):完整协作流(邀请三角色→提交→批准→发布)、拒绝路径、成员移除失效与外人隔离 --- ...6c0a63816_协作空间_成员_邀请_项目与评审.py | 207 +++++++++++++ backend/app/main.py | 2 + backend/app/models.py | 126 ++++++++ backend/app/workspaces/__init__.py | 1 + backend/app/workspaces/routes.py | 181 ++++++++++++ backend/app/workspaces/service.py | 272 ++++++++++++++++++ backend/tests/test_conversations.py | 155 ++++++++++ 7 files changed, 944 insertions(+) create mode 100644 backend/alembic/versions/bd36c0a63816_协作空间_成员_邀请_项目与评审.py create mode 100644 backend/app/workspaces/__init__.py create mode 100644 backend/app/workspaces/routes.py create mode 100644 backend/app/workspaces/service.py diff --git a/backend/alembic/versions/bd36c0a63816_协作空间_成员_邀请_项目与评审.py b/backend/alembic/versions/bd36c0a63816_协作空间_成员_邀请_项目与评审.py new file mode 100644 index 0000000..3e9e6c7 --- /dev/null +++ b/backend/alembic/versions/bd36c0a63816_协作空间_成员_邀请_项目与评审.py @@ -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 ### diff --git a/backend/app/main.py b/backend/app/main.py index 458afed..b8ddf11 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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]: diff --git a/backend/app/models.py b/backend/app/models.py index bb45e0e..117be16 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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), + ) diff --git a/backend/app/workspaces/__init__.py b/backend/app/workspaces/__init__.py new file mode 100644 index 0000000..6afcc4d --- /dev/null +++ b/backend/app/workspaces/__init__.py @@ -0,0 +1 @@ +"""协作域:Workspace、成员、邀请与项目评审。""" diff --git a/backend/app/workspaces/routes.py b/backend/app/workspaces/routes.py new file mode 100644 index 0000000..910de02 --- /dev/null +++ b/backend/app/workspaces/routes.py @@ -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) diff --git a/backend/app/workspaces/service.py b/backend/app/workspaces/service.py new file mode 100644 index 0000000..8dae828 --- /dev/null +++ b/backend/app/workspaces/service.py @@ -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 diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py index 96f707e..e70e70c 100644 --- a/backend/tests/test_conversations.py +++ b/backend/tests/test_conversations.py @@ -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