- 迁移 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):完整协作流(邀请三角色→提交→批准→发布)、拒绝路径、成员移除失效与外人隔离
273 lines
9.5 KiB
Python
273 lines
9.5 KiB
Python
"""协作域: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
|