Files
chenxin 795df0413e ✨ 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):完整协作流(邀请三角色→提交→批准→发布)、拒绝路径、成员移除失效与外人隔离
2026-09-12 09:33:29 +08:00

182 lines
5.5 KiB
Python

"""协作域路由(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)