✨ 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:
1 parent
99985f5855
commit
795df0413e
7 files changed
+944
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user