✨ feat: WP-12 协作空间——Workspace、RBAC、邀请与项目评审

- 迁移 13:workspace、workspace_member(四角色 + (workspace,member) 唯一)、workspace_invitation(四态流转)、project、project_version((project,version) 唯一、评审四态、提交/评审人留痕)
- RBAC 三权分立:editor 提交版本、reviewer 批准/拒绝、owner 发布与成员管理——权限矩阵代码化(editor 不可自审、viewer 不可提交、发布前必须有 approved 当前版本)
- 邀请流:owner 邀请(角色限定 editor/reviewer/viewer)→ 被邀者接受入成员;已是成员 409;移除所有者 422
- 评审流:版本号自动递增不可变;批准设为当前版本;拒绝不可逆(重审 409);未批准发布 422
- 隔离:非成员与被移除成员统一 404 fail-closed(require_member 单点)
- 测试 82 项全绿(+3):完整协作流(邀请三角色→提交→批准→发布)、拒绝路径、成员移除失效与外人隔离
This commit is contained in:
chenxin committed 2026-09-12 09:33:29 +08:00
1 parent 99985f5855
commit 795df0413e
7 files changed
+944

No files matched your search

+2
View File
@@ -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]:
+126
View File
@@ -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),
)
+1
View File
@@ -0,0 +1 @@
"""协作域:Workspace、成员、邀请与项目评审。"""
+181
View File
@@ -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)
+272
View File
@@ -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