"""协作域: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