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