diff --git a/CLAUDE.md b/CLAUDE.md
index 00fa22b..d106ebb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -123,4 +123,16 @@ Lessons from splitting `frontend/app/features/schedules/state/schedulesStore.ts`
### Frontend coupling
-- **`UserManagementPage.tsx` and `api.ts` still call `/api/v1/admin/employees`.** Don't migrate them in the same change as a `/api/v1/platform/employees` addition — the contract surface is intentionally duplicated.
\ No newline at end of file
+- **`UserManagementPage.tsx` and `api.ts` still call `/api/v1/admin/employees`.** Don't migrate them in the same change as a `/api/v1/platform/employees` addition — the contract surface is intentionally duplicated.
+
+### File size: 500 lines hard cap
+
+- **每个文件最多 500 行** — 超过时主动拆分,不要等 review 才动。
+- 常见拆分维度:
+ - **后端 — 按资源 / 端点分组**:例如 `api/platform.py` (1600 行 / 20 endpoint) → `api/platform/{__init__,_deps,employees,roles,workspaces}.py`。原文件改为 thin shim,只 re-export 公共符号,保持 `from backend.api.platform import router` 等既有 import path 不变。
+ - **后端 — 按层级**:参考 `schedule/` 已有的 `domain/` / `application/` / `infrastructure/` 分层。
+ - **前端 — 按职责**:`types.ts` / `helpers.ts` / `slices/` / `useXStore.ts`(参考 `useSchedulesStore` 拆 8-slice 的纪律)。
+ - **前端 — 页面 vs 路由 wrapper**:Page 组件持有 useState,route 文件只做 `` 重挂载,二者不要混在一起。
+- **拆分前先列调用面**(`grep "from "`),任何外部 import 路径必须仍然可用 — 用 re-export 或 shim 兜底,不要让调用方被迫改。
+- **拆分后**每个新文件 ≤ 500 行是硬约束,验证方式:`wc -l ` 或 CI 脚本。
+- 拆分本身是**纯结构调整**,endpoint 行为 / URL / 响应 schema 零变化 — 不要顺手"清理"。
\ No newline at end of file
diff --git a/backend/src/backend/api/platform.py b/backend/src/backend/api/platform.py
index f251985..dc480ff 100644
--- a/backend/src/backend/api/platform.py
+++ b/backend/src/backend/api/platform.py
@@ -1,1316 +1,8 @@
-"""系统级管理接口。
-
-中文导读:本模块管理全平台的用户、工作区、成员关系和角色权限。它使用
-``system_admin_context`` 进行平台管理员校验,因此不要求请求者先加入某个具体
-工作区;普通工作区内的业务接口则使用 ``request_context``。
-
-System-admin (platform-scope) endpoints for workspace & membership management.
-
-All routes under ``/api/v1/platform/*`` are gated by
-:func:`system_admin_context`, which requires the requester to hold a
-``Users.platform_role_id`` pointing to a ``Roles`` row whose
-``role_code == 'admin'``. Unlike ``backend.api.dependencies.request_context``,
-this dependency does NOT require an active workspace membership — system
-admins can manage workspaces before/without being a member of any.
-
-Endpoints
----------
-
-Workspace CRUD::
-
- GET /workspaces — list non-deleted workspaces
- POST /workspaces — create a new workspace
- GET /workspaces/{workspace_id} — single workspace (incl. disabled)
- PATCH /workspaces/{workspace_id} — update editable fields
- DELETE /workspaces/{workspace_id} — soft delete (cascades memberships)
-
-Workspace membership CRUD::
-
- GET /workspaces/{workspace_id}/members — list active members
- POST /workspaces/{workspace_id}/members — add a member
- PATCH /workspaces/{workspace_id}/members/{user_id} — update role/status
- DELETE /workspaces/{workspace_id}/members/{user_id} — remove a member
-
-Platform employee roster::
-
- GET /employees — list all non-deleted users
- POST /employees — create a user without workspace membership
- PATCH /employees/{user_id} — update a user's profile, status or platform role
- DELETE /employees/{user_id} — soft delete a user (cascades to workspace memberships)
-
-Role menu-permission management::
-
- GET /roles — list platform roles with their permission_codes
- GET /roles/{role_code}/permissions — one role's permission_codes
- PATCH /roles/{role_code}/permissions — replace a role's permission set (diff-based)
-
-Invariants
-----------
-
-* Every workspace must always retain at least one active ``admin`` member.
- This is enforced on member PATCH/DELETE AND on
- ``PATCH /employees/{user_id}`` demotions, because workspace role is
- inherited from ``users.platform_role_id`` and demoting a platform
- admin cascades to all of their active memberships.
-* A system admin cannot remove their own workspace membership via
- ``DELETE .../members/{self}``; the only escape is to delete the entire
- workspace, which cascades membership soft-deletion.
-* ``DELETE /workspaces/{id}`` is allowed from any non-disabled status and
- sets ``status='disabled'`` + ``is_deleted=1`` + ``deleted_at`` on the
- workspace and every one of its active memberships.
-* The ``admin`` role must always keep ``system:view``.
- menu permissions; non-admin roles may never hold ``system.*``
- permissions. Menu permissions gate frontend rendering only — API
- authorization always keys off ``role_code == 'admin'``.
-"""
-
-from __future__ import annotations
-
-import datetime
-import re
-from dataclasses import dataclass
-from typing import Any, Literal
-
-from common.auth.passwords import hash_password
-from common.db.models import (
- Permissions,
- RolePermissions,
- Roles,
- Users,
- WorkspaceMembers,
- Workspaces,
+"""Backward-compat shim — see backend.api.platform package."""
+from backend.api.platform import (
+ SystemAdminContext,
+ router,
+ system_admin_context,
)
-from common.ids import new_ulid
-from fastapi import APIRouter, Depends, HTTPException, Request, status
-from pydantic import BaseModel, ConfigDict, Field
-from sqlalchemy import func, insert, or_, select, update
-from sqlalchemy.ext.asyncio import AsyncSession
-from backend.api.dependencies import current_user, database_session
-
-router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
-
-
-# ---------------------------------------------------------------------------
-# Constants
-# ---------------------------------------------------------------------------
-
-WORKSPACE_CODE_PATTERN = re.compile(r"^[a-z0-9-]{3,32}$")
-LIST_PAGE_SIZE = 100
-
-WORKSPACE_EDITABLE_STATUS = ("active", "archived")
-MEMBER_ROLE_CODES = ("admin", "developer")
-MEMBER_STATUS_VALUES = ("active", "disabled", "locked")
-
-
-# ---------------------------------------------------------------------------
-# Schemas
-# ---------------------------------------------------------------------------
-
-
-# 创建工作区时前端提交的请求体;禁止未声明字段。
-class WorkspaceCreate(BaseModel):
- model_config = ConfigDict(extra="forbid")
-
- workspace_code: str = Field(min_length=3, max_length=32)
- workspace_name: str = Field(min_length=1, max_length=150)
- quota_bytes: int = Field(default=0, ge=0)
- description: str | None = Field(default=None, max_length=1000)
-
-
-# 编辑工作区时允许修改的字段;禁用操作必须走删除接口而不是直接传状态。
-class WorkspaceUpdate(BaseModel):
- model_config = ConfigDict(extra="forbid")
-
- workspace_name: str | None = Field(default=None, min_length=1, max_length=150)
- quota_bytes: int | None = Field(default=None, ge=0)
- description: str | None = Field(default=None, max_length=1000)
- # 'disabled' is rejected here on purpose — soft delete must go through DELETE.
- status: Literal["active", "archived"] | None = None
-
-
-# 将已存在用户加入工作区的请求体;角色继承用户的平台角色。
-class MemberCreate(BaseModel):
- """Add a user to a workspace. Role is inherited from the user's
- platform role (Users.platform_role_id) — not set here."""
-
- model_config = ConfigDict(extra="forbid")
-
- user_id: str = Field(min_length=26, max_length=26)
-
-
-# 更新成员在该工作区中的可用状态,不直接在这里修改平台角色。
-class MemberUpdate(BaseModel):
- """Update a workspace membership's status. Role cannot be changed
- via this endpoint — workspace role is always inherited from the
- user's platform role. To change a member's role, PATCH
- /platform/employees/{user_id} instead."""
-
- model_config = ConfigDict(extra="forbid")
-
- member_status: Literal["active", "disabled", "locked"] | None = None
-
-
-# 新建平台用户的请求体;创建用户不等同于把用户加入某个工作区。
-class PlatformEmployeeCreate(BaseModel):
- model_config = ConfigDict(extra="forbid")
-
- username: str = Field(min_length=2, max_length=64)
- display_name: str = Field(min_length=1, max_length=100)
- email: str | None = Field(default=None, max_length=255)
- password: str = Field(min_length=8, max_length=72)
- role_code: Literal["admin", "developer"] | None = None
-
-
-# 修改平台用户资料、状态或平台角色的请求体。
-class PlatformEmployeeUpdate(BaseModel):
- model_config = ConfigDict(extra="forbid")
-
- display_name: str | None = Field(default=None, min_length=1, max_length=100)
- email: str | None = Field(default=None, max_length=255)
- status: Literal["active", "disabled", "locked"] | None = None
- role_code: Literal["admin", "developer"] | None = None
-
-
-# 用完整权限集合替换某个平台角色菜单权限的请求体。
-class RolePermissionsPatch(BaseModel):
- """Replace a platform role's permission set wholesale.
-
- Empty list is allowed (revokes all permissions) for non-`admin`
- roles. The PATCH endpoint rejects emptying an `admin` role of its
- system.* permissions; see ``patch_role_permissions`` for the
- load-bearing guard order.
- """
-
- model_config = ConfigDict(extra="forbid")
-
- permission_codes: list[str] = Field(default_factory=list, max_length=64)
-
-
-# ---------------------------------------------------------------------------
-# System-admin context dependency
-# ---------------------------------------------------------------------------
-
-
-@dataclass(frozen=True)
-class SystemAdminContext:
- """通过系统管理员校验后的上下文,只包含当前用户和请求追踪 ID。"""
-
- """Resolved identity for a system-admin request.
-
- Carries the request id, the authenticated user row, and the resolved
- ``Roles`` row the user holds via ``Users.platform_role_id``. By
- construction the role's ``role_code`` is ``"admin"``.
- """
-
- request_id: str
- user: Users
- platform_role: Roles
-
-
-async def system_admin_context(
- request: Request,
- session: AsyncSession = Depends(database_session),
-) -> SystemAdminContext:
- """验证当前用户是否为平台管理员,供 /api/v1/platform 下的路由依赖。"""
- """Resolve the requester as a system admin.
-
- Steps:
- 1. Reuse :func:`backend.api.dependencies.current_user` to validate the JWT
- cookie and fetch the active ``Users`` row (raises 401 on failure).
- 2. Require ``Users.platform_role_id`` to point to a row whose
- ``role_code == 'admin'`` — anything else is 403.
- """
- user = await current_user(request, session)
- if user.platform_role_id is None:
- raise HTTPException(
- status.HTTP_403_FORBIDDEN,
- "需要系统管理员权限",
- )
- platform_role = await session.scalar(
- select(Roles).where(Roles.role_id == user.platform_role_id)
- )
- if platform_role is None or platform_role.role_code != "admin":
- raise HTTPException(
- status.HTTP_403_FORBIDDEN,
- "需要系统管理员权限",
- )
- request_id = request.headers.get("X-Request-ID") or new_ulid()
- return SystemAdminContext(
- request_id=request_id,
- user=user,
- platform_role=platform_role,
- )
-
-
-async def _is_system_admin(session: AsyncSession, user: Users) -> bool:
- """True if ``user`` holds the platform-scoped admin role.
-
- Mirrors the check inside :func:`system_admin_context` so member-listing
- endpoints can admit workspace members *or* system admins without pulling
- in the full :class:`SystemAdminContext` (which 403s non-admins outright).
- """
- if user.platform_role_id is None:
- return False
- platform_role = await session.scalar(
- select(Roles).where(Roles.role_id == user.platform_role_id)
- )
- return platform_role is not None and platform_role.role_code == "admin"
-
-
-# ---------------------------------------------------------------------------
-# Payload helpers
-# ---------------------------------------------------------------------------
-
-
-def workspace_payload(workspace: Workspaces) -> dict[str, Any]:
- return {
- "workspace_id": workspace.workspace_id,
- "workspace_code": workspace.workspace_code,
- "workspace_name": workspace.workspace_name,
- "active_root_uri": workspace.active_root_uri,
- "quota_bytes": workspace.quota_bytes,
- "status": workspace.status,
- "description": workspace.description,
- "created_by": workspace.created_by,
- "created_at": workspace.created_at.isoformat(),
- "updated_at": (
- workspace.updated_at.isoformat() if workspace.updated_at else None
- ),
- }
-
-
-def member_payload(
- user: Users,
- role: Roles,
- membership: WorkspaceMembers,
-) -> dict[str, Any]:
- return {
- "user_id": user.user_id,
- "username": user.username,
- "display_name": user.display_name,
- "email": user.email,
- "user_status": user.status,
- "role_code": role.role_code,
- "role_name": role.role_name,
- "member_status": membership.member_status,
- "joined_at": membership.joined_at.isoformat(),
- }
-
-
-def platform_employee_payload(
- user: Users,
- role: Roles | None,
-) -> dict[str, Any]:
- return {
- "user_id": user.user_id,
- "username": user.username,
- "display_name": user.display_name,
- "email": user.email,
- "status": user.status,
- "role_code": role.role_code if role is not None else None,
- "role_name": role.role_name if role is not None else None,
- "created_at": user.created_at.isoformat(),
- }
-
-
-# ---------------------------------------------------------------------------
-# Internal helpers
-# ---------------------------------------------------------------------------
-
-
-async def _load_workspace(session: AsyncSession, workspace_id: str) -> Workspaces:
- workspace = await session.get(Workspaces, workspace_id)
- if workspace is None:
- raise HTTPException(status.HTTP_404_NOT_FOUND, "workspace 不存在")
- return workspace
-
-
-async def _load_role_by_code(session: AsyncSession, role_code: str) -> Roles:
- role = await session.scalar(select(Roles).where(Roles.role_code == role_code))
- if role is None:
- raise HTTPException(
- status.HTTP_422_UNPROCESSABLE_ENTITY,
- f"角色 {role_code} 不存在",
- )
- return role
-
-
-async def _count_active_admins(
- session: AsyncSession,
- workspace_id: str,
- exclude_user_id: str | None = None,
-) -> int:
- """Count active admin members of ``workspace_id``.
-
- Pass ``exclude_user_id`` when checking "would X be the last admin?"
- before mutating X.
- """
- admin_role = await _load_role_by_code(session, "admin")
- stmt = (
- select(func.count())
- .select_from(WorkspaceMembers)
- .where(
- WorkspaceMembers.workspace_id == workspace_id,
- WorkspaceMembers.role_id == admin_role.role_id,
- WorkspaceMembers.member_status == "active",
- WorkspaceMembers.is_deleted == 0,
- )
- )
- if exclude_user_id is not None:
- stmt = stmt.where(WorkspaceMembers.user_id != exclude_user_id)
- return int(await session.scalar(stmt) or 0)
-
-
-async def _count_active_system_admins(
- session: AsyncSession,
- exclude_user_id: str | None = None,
-) -> int:
- """Count active system admins across the platform.
-
- Pass ``exclude_user_id`` when checking "would X be the last admin?"
- before mutating X.
- """
- admin_role = await _load_role_by_code(session, "admin")
- stmt = (
- select(func.count())
- .select_from(Users)
- .where(
- Users.status == "active",
- Users.is_deleted == 0,
- Users.platform_role_id == admin_role.role_id,
- )
- )
- if exclude_user_id is not None:
- stmt = stmt.where(Users.user_id != exclude_user_id)
- return int(await session.scalar(stmt) or 0)
-
-
-def _envelope(request_id: str, data: Any, meta: dict[str, Any] | None = None) -> dict[str, Any]:
- return {
- "request_id": request_id,
- "data": data,
- "meta": meta or {},
- }
-
-
-# ---------------------------------------------------------------------------
-# Platform employee roster
-# ---------------------------------------------------------------------------
-
-
-# 列出整个平台的非删除用户;不局限于某一个工作区。
-@router.get("/employees")
-async def list_platform_employees(
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """List every non-soft-deleted platform user."""
- rows = (
- await session.execute(
- select(Users, Roles)
- .outerjoin(Roles, Roles.role_id == Users.platform_role_id)
- .where(Users.is_deleted == 0)
- .order_by(Users.created_at, Users.user_id)
- )
- ).all()
- return _envelope(
- context.request_id,
- [platform_employee_payload(user, role) for user, role in rows],
- {"count": len(rows)},
- )
-
-
-# 创建平台用户;后续可再通过成员接口把该用户加入工作区。
-@router.post("/employees", status_code=status.HTTP_201_CREATED)
-async def create_platform_employee(
- payload: PlatformEmployeeCreate,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Create a platform user without assigning workspace membership."""
- username = payload.username.strip()
- display_name = payload.display_name.strip()
- duplicate_conditions = [Users.username == username]
- if payload.email:
- duplicate_conditions.append(Users.email == payload.email.strip())
- duplicate = await session.scalar(
- select(Users.user_id).where(or_(*duplicate_conditions))
- )
- if duplicate is not None:
- raise HTTPException(status.HTTP_409_CONFLICT, "用户名或邮箱已存在")
-
- new_role: Roles | None = None
- if payload.role_code is not None:
- new_role = await _load_role_by_code(session, payload.role_code)
-
- user = Users(
- user_id=new_ulid(),
- username=username,
- display_name=display_name,
- email=payload.email.strip() if payload.email else None,
- password_hash=hash_password(payload.password),
- status="active",
- platform_role_id=new_role.role_id if new_role is not None else None,
- )
- session.add(user)
- await session.flush()
- await session.refresh(user)
- return _envelope(
- context.request_id,
- platform_employee_payload(user, new_role),
- )
-
-
-# 更新平台用户资料、账号状态或平台角色,同时保护最少管理员等约束。
-@router.patch("/employees/{user_id}")
-async def update_platform_employee(
- user_id: str,
- payload: PlatformEmployeeUpdate,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Update a platform user's profile, status, or platform role.
-
- Changing ``role_code`` cascades: every active ``workspace_members``
- row of the user is rewritten to the new role (workspace role is
- inherited from the platform role). Demoting admin → developer is
- rejected with 409 when it would leave any workspace without an
- active admin member, or the platform without an active system
- admin. Self-demotion is always rejected.
- """
- user = await session.get(Users, user_id)
- if user is None or user.is_deleted != 0:
- raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
-
- is_self = user_id == context.user.user_id
- if (
- is_self
- and payload.status is not None
- and payload.status != "active"
- ):
- raise HTTPException(status.HTTP_409_CONFLICT, "不能停用当前登录账号")
-
- current_role: Roles | None = None
- if user.platform_role_id is not None:
- current_role = await session.scalar(
- select(Roles).where(Roles.role_id == user.platform_role_id)
- )
- is_current_system_admin = (
- user.status == "active"
- and current_role is not None
- and current_role.role_code == "admin"
- )
-
- new_role: Roles | None = None
- if payload.role_code is not None:
- new_role = await _load_role_by_code(session, payload.role_code)
- next_status = payload.status if payload.status is not None else user.status
-
- leaves_admin_pool = (
- is_current_system_admin
- and (
- next_status != "active"
- or (new_role is not None and new_role.role_code != "admin")
- )
- )
- if leaves_admin_pool:
- remaining = await _count_active_system_admins(
- session, exclude_user_id=user_id,
- )
- if remaining == 0:
- raise HTTPException(
- status.HTTP_409_CONFLICT,
- "platform 必须保留至少一个 active 系统管理员",
- )
-
- # Workspace-level last-admin guard for the demote path. The role_code
- # sync below rewrites ``workspace_members.role_id`` for every active
- # membership of this user, so demoting admin → developer would
- # silently strip workspace admin coverage anywhere this user is the
- # sole active admin member. ``update_member`` / ``remove_member``
- # guard the same invariant via ``_count_active_admins``; this
- # endpoint must too, now that it can change workspace roles.
- demotes_admin = (
- is_current_system_admin
- and new_role is not None
- and new_role.role_code != "admin"
- )
- if demotes_admin:
- assert current_role is not None # implied by is_current_system_admin
- admin_memberships = (
- await session.execute(
- select(WorkspaceMembers.workspace_id)
- .where(
- WorkspaceMembers.user_id == user_id,
- WorkspaceMembers.role_id == current_role.role_id,
- WorkspaceMembers.member_status == "active",
- WorkspaceMembers.is_deleted == 0,
- )
- )
- ).all()
- orphaned: list[str] = []
- for (ws_id,) in admin_memberships:
- remaining_ws = await _count_active_admins(
- session, ws_id, exclude_user_id=user_id,
- )
- if remaining_ws == 0:
- orphaned.append(ws_id)
- if orphaned:
- codes = (
- await session.execute(
- select(Workspaces.workspace_code).where(
- Workspaces.workspace_id.in_(orphaned)
- )
- )
- ).all()
- names = sorted(row[0] for row in codes)
- raise HTTPException(
- status.HTTP_409_CONFLICT,
- f"以下 workspace 将失去唯一 active admin: {names};"
- "请先在这些 workspace 中指定其他 admin,再降级该用户",
- )
-
- if is_self and new_role is not None and new_role.role_code != "admin":
- raise HTTPException(status.HTTP_409_CONFLICT, "不能降级自身管理员角色")
-
- if payload.display_name is not None:
- user.display_name = payload.display_name.strip()
- if payload.email is not None:
- user.email = payload.email.strip() or None
- if payload.status is not None:
- user.status = payload.status
- if new_role is not None:
- user.platform_role_id = new_role.role_id
- # Workspace role is always inherited from the platform role
- # (§7.5/§7.6 cannot change it). Keep workspace_members.role_id
- # in sync so downstream reads — `/me` workspaces[].role_code,
- # load_active_membership, §7.7 DELETE last-admin guard —
- # see the up-to-date role. Without this sync, a user demoted
- # from admin → developer would still appear as admin in every
- # workspace they belong to until they leave and re-join.
- await session.execute(
- update(WorkspaceMembers)
- .where(
- WorkspaceMembers.user_id == user.user_id,
- WorkspaceMembers.is_deleted == 0,
- )
- .values(role_id=new_role.role_id)
- )
-
- await session.flush()
- await session.refresh(user)
-
- response_role: Roles | None = None
- if user.platform_role_id is not None:
- response_role = await session.scalar(
- select(Roles).where(Roles.role_id == user.platform_role_id)
- )
- return _envelope(
- context.request_id, platform_employee_payload(user, response_role),
- )
-
-
-# 软删除平台用户,并级联标记其工作区成员关系为删除。
-@router.delete("/employees/{user_id}")
-async def delete_platform_employee(
- user_id: str,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Soft delete a platform user and cascade-soft-delete workspace memberships."""
- user = await session.get(Users, user_id)
- if user is None or user.is_deleted != 0:
- raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
-
- if user_id == context.user.user_id:
- raise HTTPException(status.HTTP_409_CONFLICT, "不能删除当前登录账号")
-
- if user.status == "active" and user.platform_role_id is not None:
- current_admin_role = await session.scalar(
- select(Roles).where(Roles.role_id == user.platform_role_id)
- )
- if current_admin_role is not None and current_admin_role.role_code == "admin":
- remaining = await _count_active_system_admins(
- session, exclude_user_id=user_id,
- )
- if remaining == 0:
- raise HTTPException(
- status.HTTP_409_CONFLICT,
- "platform 必须保留至少一个 active 系统管理员",
- )
-
- now = datetime.datetime.utcnow()
- user.status = "disabled"
- user.is_deleted = 1
- user.deleted_at = now
- await session.execute(
- update(WorkspaceMembers)
- .where(
- WorkspaceMembers.user_id == user_id,
- WorkspaceMembers.is_deleted == 0,
- )
- .values(is_deleted=1, deleted_at=now)
- )
- await session.flush()
- return _envelope(
- context.request_id,
- {"user_id": user_id, "deleted": True},
- )
-
-
-# ---------------------------------------------------------------------------
-# Workspace CRUD
-# ---------------------------------------------------------------------------
-
-
-# 列出平台中全部未删除工作区。
-@router.get("/workspaces")
-async def list_workspaces(
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """List active/archived workspaces. Soft-deleted rows are filtered out.
-
- Silent ``pageSize=100`` cap — YAGNI on real pagination until needed.
- """
- rows = (
- await session.execute(
- select(Workspaces)
- .where(
- Workspaces.status != "disabled",
- Workspaces.is_deleted == 0,
- )
- .order_by(Workspaces.created_at, Workspaces.workspace_id)
- .limit(LIST_PAGE_SIZE)
- )
- ).scalars().all()
- return _envelope(
- context.request_id,
- [workspace_payload(w) for w in rows],
- {"count": len(rows), "page_size": LIST_PAGE_SIZE},
- )
-
-
-# 创建工作区,并将当前系统管理员初始化为该工作区管理员。
-@router.post("/workspaces", status_code=status.HTTP_201_CREATED)
-async def create_workspace(
- payload: WorkspaceCreate,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Create a workspace and auto-join the creator as an admin member."""
- if not WORKSPACE_CODE_PATTERN.fullmatch(payload.workspace_code):
- raise HTTPException(
- status.HTTP_422_UNPROCESSABLE_ENTITY,
- "workspace_code 必须匹配 ^[a-z0-9-]{3,32}$",
- )
- duplicate = await session.scalar(
- select(Workspaces.workspace_id).where(
- Workspaces.workspace_code == payload.workspace_code,
- )
- )
- if duplicate is not None:
- raise HTTPException(status.HTTP_409_CONFLICT, "workspace_code 已存在")
-
- admin_role = await _load_role_by_code(session, "admin")
- workspace_id = new_ulid()
- workspace = Workspaces(
- workspace_id=workspace_id,
- workspace_code=payload.workspace_code,
- workspace_name=payload.workspace_name,
- active_root_uri=f"s3://workspaces/{workspace_id}/",
- quota_bytes=payload.quota_bytes,
- status="active",
- created_by=context.user.user_id,
- description=payload.description,
- )
- session.add(workspace)
- session.add(
- WorkspaceMembers(
- workspace_id=workspace_id,
- user_id=context.user.user_id,
- role_id=admin_role.role_id,
- member_status="active",
- )
- )
- await session.flush()
- await session.refresh(workspace)
- return _envelope(context.request_id, workspace_payload(workspace))
-
-
-# 读取单个工作区详情,包含已归档或禁用状态。
-@router.get("/workspaces/{workspace_id}")
-async def get_workspace(
- workspace_id: str,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Fetch a single workspace — even soft-deleted ones are reachable."""
- workspace = await _load_workspace(session, workspace_id)
- return _envelope(context.request_id, workspace_payload(workspace))
-
-
-# 更新工作区可编辑属性,例如名称、配额、描述和归档状态。
-@router.patch("/workspaces/{workspace_id}")
-async def update_workspace(
- workspace_id: str,
- payload: WorkspaceUpdate,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Patch editable workspace fields. ``status='disabled'`` is rejected."""
- workspace = await _load_workspace(session, workspace_id)
- if workspace.status == "disabled":
- raise HTTPException(
- status.HTTP_409_CONFLICT,
- "workspace 已删除,无法修改",
- )
- if payload.workspace_name is not None:
- workspace.workspace_name = payload.workspace_name.strip()
- if payload.quota_bytes is not None:
- workspace.quota_bytes = payload.quota_bytes
- if payload.description is not None:
- workspace.description = payload.description
- if payload.status is not None:
- workspace.status = payload.status
- await session.flush()
- await session.refresh(workspace)
- return _envelope(context.request_id, workspace_payload(workspace))
-
-
-# 软删除/禁用工作区,并级联处理其活动成员关系。
-@router.delete("/workspaces/{workspace_id}")
-async def delete_workspace(
- workspace_id: str,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Soft-delete a workspace and cascade-soft-delete its memberships.
-
- Allowed from any non-disabled status (active or archived). The
- membership cascade is what lets system admins leave a workspace —
- there is no per-member DELETE escape for self-removal.
- """
- workspace = await _load_workspace(session, workspace_id)
- if workspace.status == "disabled":
- raise HTTPException(
- status.HTTP_409_CONFLICT,
- "workspace 已被删除",
- )
- now = datetime.datetime.utcnow()
- workspace.status = "disabled"
- workspace.is_deleted = 1
- workspace.deleted_at = now
- await session.execute(
- update(WorkspaceMembers)
- .where(
- WorkspaceMembers.workspace_id == workspace_id,
- WorkspaceMembers.is_deleted == 0,
- )
- .values(is_deleted=1, deleted_at=now)
- )
- await session.flush()
- await session.refresh(workspace)
- return _envelope(context.request_id, workspace_payload(workspace))
-
-
-# ---------------------------------------------------------------------------
-# Workspace membership CRUD
-# ---------------------------------------------------------------------------
-
-
-# 列出一个工作区的活动成员与成员状态。
-@router.get("/workspaces/{workspace_id}/members")
-async def list_members(
- workspace_id: str,
- request: Request,
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """List active and historical (non-soft-deleted) members of a workspace.
-
- Accessible to system admins (any workspace) and to active members of the
- workspace itself. The script explorer calls this to seed the per-owner
- directory-tree groups for non-admin users; visibility filters on the
- scripts/data-resources endpoints still keep each peer's private content
- hidden, so this only exposes membership (names), not private files.
- """
- user = await current_user(request, session)
- is_system_admin = await _is_system_admin(session, user)
- if not is_system_admin:
- membership = await session.scalar(
- select(WorkspaceMembers).where(
- WorkspaceMembers.workspace_id == workspace_id,
- WorkspaceMembers.user_id == user.user_id,
- WorkspaceMembers.is_deleted == 0,
- WorkspaceMembers.member_status == "active",
- )
- )
- if membership is None:
- raise HTTPException(
- status.HTTP_403_FORBIDDEN,
- "需要系统管理员或该工作区成员权限",
- )
- await _load_workspace(session, workspace_id)
- rows = (
- await session.execute(
- select(Users, Roles, WorkspaceMembers)
- .join(
- WorkspaceMembers,
- WorkspaceMembers.user_id == Users.user_id,
- )
- .join(Roles, Roles.role_id == WorkspaceMembers.role_id)
- .where(
- WorkspaceMembers.workspace_id == workspace_id,
- WorkspaceMembers.is_deleted == 0,
- )
- .order_by(WorkspaceMembers.joined_at, Users.user_id)
- .limit(LIST_PAGE_SIZE)
- )
- ).all()
- request_id = request.headers.get("X-Request-ID") or new_ulid()
- return _envelope(
- request_id,
- [member_payload(u, r, m) for u, r, m in rows],
- {"count": len(rows), "page_size": LIST_PAGE_SIZE},
- )
-
-
-# 将已有平台用户加入指定工作区。
-@router.post(
- "/workspaces/{workspace_id}/members",
- status_code=status.HTTP_201_CREATED,
-)
-async def add_member(
- workspace_id: str,
- payload: MemberCreate,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Add a user to a workspace. The new row starts with member_status='active'.
-
- The role is inherited from the target user's ``platform_role_id``;
- the request body does NOT take a ``role_code``. To change a member's
- role, PATCH ``/api/v1/platform/employees/{user_id}`` instead.
- """
- await _load_workspace(session, workspace_id)
- user = await session.get(Users, payload.user_id)
- if user is None or user.is_deleted != 0:
- raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
- if user.status != "active":
- raise HTTPException(
- status.HTTP_409_CONFLICT,
- f"用户状态为 {user.status},无法加入 workspace",
- )
- if user.platform_role_id is None:
- raise HTTPException(
- status.HTTP_409_CONFLICT,
- "目标用户尚未分配平台角色,无法加入 workspace;"
- "请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code",
- )
- role = await session.scalar(
- select(Roles).where(
- Roles.role_id == user.platform_role_id,
- Roles.is_deleted == 0,
- )
- )
- if role is None:
- raise HTTPException(
- status.HTTP_422_UNPROCESSABLE_ENTITY,
- "用户的平台角色行不存在或已被删除",
- )
- # ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK,
- # 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行,
- # 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行:
- # 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK.
- existing = await session.scalar(
- select(WorkspaceMembers).where(
- WorkspaceMembers.workspace_id == workspace_id,
- WorkspaceMembers.user_id == payload.user_id,
- )
- )
- if existing is not None:
- if existing.is_deleted == 0:
- raise HTTPException(
- status.HTTP_409_CONFLICT,
- "用户已是该 workspace 成员;workspace 角色继承自平台角色,"
- "要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code",
- )
- # 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承
- # 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉
- # ``deleted_at`` 标记本轮已不在软删状态.
- existing.is_deleted = 0
- existing.deleted_at = None
- existing.role_id = role.role_id
- existing.member_status = "active"
- await session.flush()
- await session.refresh(existing)
- return _envelope(
- context.request_id, member_payload(user, role, existing),
- )
- membership = WorkspaceMembers(
- workspace_id=workspace_id,
- user_id=payload.user_id,
- role_id=role.role_id,
- member_status="active",
- )
- session.add(membership)
- await session.flush()
- await session.refresh(membership)
- return _envelope(context.request_id, member_payload(user, role, membership))
-
-
-# 更新成员状态,例如禁用或锁定;同时保证工作区不会失去最后一个管理员。
-@router.patch("/workspaces/{workspace_id}/members/{user_id}")
-async def update_member(
- workspace_id: str,
- user_id: str,
- payload: MemberUpdate,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Update a workspace membership's status. Role is not editable here.
-
- Workspace role is always inherited from the user's platform role
- (``Users.platform_role_id``). To change role, PATCH
- ``/api/v1/platform/employees/{user_id}`` instead.
-
- Last-admin guard still applies to ``member_status`` changes: setting
- the only active admin to ``disabled``/``locked`` would leave the
- workspace without admin coverage.
- """
- await _load_workspace(session, workspace_id)
- row = (
- await session.execute(
- select(Users, Roles, WorkspaceMembers)
- .join(
- WorkspaceMembers,
- WorkspaceMembers.user_id == Users.user_id,
- )
- .join(Roles, Roles.role_id == WorkspaceMembers.role_id)
- .where(
- WorkspaceMembers.workspace_id == workspace_id,
- WorkspaceMembers.user_id == user_id,
- WorkspaceMembers.is_deleted == 0,
- )
- )
- ).first()
- if row is None:
- raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
- user, role, membership = row
-
- if payload.member_status is not None and payload.member_status != membership.member_status:
- if (
- role.role_code == "admin"
- and payload.member_status != "active"
- ):
- remaining = await _count_active_admins(
- session, workspace_id, exclude_user_id=user_id,
- )
- if remaining == 0:
- raise HTTPException(
- status.HTTP_409_CONFLICT,
- "workspace 必须保留至少一个 admin",
- )
- membership.member_status = payload.member_status
-
- await session.flush()
- await session.refresh(membership)
- return _envelope(context.request_id, member_payload(user, role, membership))
-
-
-# 移除某个工作区成员,并保护最后一名管理员及当前操作者的安全约束。
-@router.delete("/workspaces/{workspace_id}/members/{user_id}")
-async def remove_member(
- workspace_id: str,
- user_id: str,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Soft-delete a workspace membership.
-
- System admins cannot remove themselves — the only escape is to delete
- the entire workspace, which cascades membership soft-deletion.
- """
- await _load_workspace(session, workspace_id)
- if user_id == context.user.user_id:
- raise HTTPException(
- status.HTTP_403_FORBIDDEN,
- "系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace",
- )
- row = (
- await session.execute(
- select(Roles, WorkspaceMembers)
- .join(Roles, Roles.role_id == WorkspaceMembers.role_id)
- .where(
- WorkspaceMembers.workspace_id == workspace_id,
- WorkspaceMembers.user_id == user_id,
- WorkspaceMembers.is_deleted == 0,
- )
- )
- ).first()
- if row is None:
- raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
- role, membership = row
- if role.role_code == "admin" and membership.member_status == "active":
- remaining = await _count_active_admins(
- session, workspace_id, exclude_user_id=user_id,
- )
- if remaining == 0:
- raise HTTPException(
- status.HTTP_409_CONFLICT,
- "workspace 必须保留至少一个 admin",
- )
- membership.is_deleted = 1
- membership.deleted_at = datetime.datetime.utcnow()
- await session.flush()
- return _envelope(
- context.request_id,
- {"workspace_id": workspace_id, "user_id": user_id, "removed": True},
- )
-
-
-# ---------------------------------------------------------------------------
-# Platform role permission management
-# ---------------------------------------------------------------------------
-#
-# Menu permissions for the platform admin UI. The auth gate
-# (system_admin_context) still keys off role_code == "admin"; these
-# endpoints only control the menu items the frontend renders, not
-# which API calls a user may make. See migrations/
-# versions/e5f6a7b8c9d0_seed_role_permissions_and_fix_scope.py for
-# the seed values.
-
-
-async def _load_platform_role_by_code(
- session: AsyncSession, role_code: str
-) -> Roles:
- """Load a platform-scoped role by code; 404 if missing or not platform-scope."""
- role = await session.scalar(
- select(Roles).where(
- Roles.role_code == role_code, Roles.is_deleted == 0,
- )
- )
- if role is None or role.role_scope != "platform":
- raise HTTPException(
- status.HTTP_404_NOT_FOUND, f"platform 角色 {role_code} 不存在",
- )
- return role
-
-
-async def _load_role_permission_codes(
- session: AsyncSession, role_id: str
-) -> list[str]:
- """Return the active permission_codes for a role, ordered by code."""
- rows = (
- await session.execute(
- select(Permissions.permission_code)
- .join(
- RolePermissions,
- RolePermissions.permission_id == Permissions.permission_id,
- )
- .where(
- RolePermissions.role_id == role_id,
- RolePermissions.is_deleted == 0,
- Permissions.is_deleted == 0,
- )
- .order_by(Permissions.permission_code)
- )
- ).all()
- return [row[0] for row in rows]
-
-
-def _role_payload(role: Roles, permission_codes: list[str]) -> dict[str, Any]:
- return {
- "role_id": role.role_id,
- "role_code": role.role_code,
- "role_name": role.role_name,
- "is_builtin": bool(role.is_builtin),
- "permission_codes": permission_codes,
- }
-
-
-# 列出平台角色及其拥有的菜单权限代码。
-@router.get("/roles")
-async def list_platform_roles(
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """List every platform-scoped role with its current permission_codes."""
- roles = (
- (
- await session.scalars(
- select(Roles)
- .where(Roles.role_scope == "platform", Roles.is_deleted == 0)
- .order_by(Roles.role_code)
- )
- ).all()
- )
- payload = []
- for role in roles:
- codes = await _load_role_permission_codes(session, role.role_id)
- payload.append(_role_payload(role, codes))
- return _envelope(
- context.request_id, payload, {"count": len(payload)},
- )
-
-
-# 获取一个角色当前配置的权限代码集合。
-@router.get("/roles/{role_code}/permissions")
-async def get_role_permissions(
- role_code: str,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Return one platform role's permission_codes."""
- role = await _load_platform_role_by_code(session, role_code)
- codes = await _load_role_permission_codes(session, role.role_id)
- return _envelope(
- context.request_id, _role_payload(role, codes),
- )
-
-
-# 以请求中的完整集合更新角色权限,并保留管理员角色的必要系统权限。
-@router.patch("/roles/{role_code}/permissions")
-async def patch_role_permissions(
- role_code: str,
- payload: RolePermissionsPatch,
- context: SystemAdminContext = Depends(system_admin_context),
- session: AsyncSession = Depends(database_session),
-) -> dict[str, Any]:
- """Replace a platform role's permission set wholesale.
-
- Guard order:
-
- 1. Load the role. Reject 404 if it is missing or not
- platform-scoped.
- 2. Admin role: the patched ``permission_codes`` MUST still include
- ``system:view``. Otherwise every
- active admin loses the menu entry to this very endpoint and
- the platform locks itself out. Reject with 409. (No last-admin
- count is needed here — menu permissions never gate API access;
- ``system_admin_context`` keys off ``role_code == 'admin'``.)
- 3. Non-admin role: ``system.*`` codes are rejected with 422 —
- they would render a system-admin menu entry whose API calls
- all 403.
- 4. Validate every code resolves to a non-deleted ``Permissions``
- row; unknown codes → 422.
- 5. Write: diff-based. Only soft-delete codes leaving the set,
- only insert codes entering it. The ``(role_id, permission_id)``
- PRIMARY KEY still occupies soft-deleted rows, so a blanket
- "delete-all then insert-all" would IntegrityError.
- Repeat-with-same-payload is a no-op.
- """
- role = await _load_platform_role_by_code(session, role_code)
-
- new_codes = list(dict.fromkeys(payload.permission_codes))
-
- if role.role_code == "admin":
- keeps_admin_entry = (
- "system:view" in new_codes
- )
- if not keeps_admin_entry:
- raise HTTPException(
- status.HTTP_409_CONFLICT,
- "admin 角色必须保留 system:view 权限",
- )
- else:
- # Menu permissions are a frontend-display signal only — backend
- # authorization keeps keying off role_code == "admin". Letting a
- # non-admin role hold system.* permissions would render the
- # system-admin entry in the developer's UI while every
- # /api/v1/platform/* call still returns 403. Reject with 422 so
- # the failure is unambiguous about *what* the input violated.
- leaked_system = [
- code for code in new_codes if code.startswith("system:")
- ]
- if leaked_system:
- raise HTTPException(
- status.HTTP_422_UNPROCESSABLE_ENTITY,
- f"非 admin 角色不能拥有 system.* 权限: {leaked_system}",
- )
-
- # 4. Validate every requested permission_code exists and is live.
- if new_codes:
- rows = (
- await session.execute(
- select(Permissions.permission_code).where(
- Permissions.permission_code.in_(new_codes),
- Permissions.is_deleted == 0,
- )
- )
- ).all()
- found = {row[0] for row in rows}
- missing = [code for code in new_codes if code not in found]
- if missing:
- raise HTTPException(
- status.HTTP_422_UNPROCESSABLE_ENTITY,
- f"未知的 permission_code: {missing}",
- )
-
- # 5. Write: diff-based soft-delete + insert.
- # The (role_id, permission_id) PRIMARY KEY still occupies the slot
- # of soft-deleted rows, so a "delete-all then insert-all" approach
- # would IntegrityError on any code that was already linked.
- # Instead: only soft-delete codes NOT in the new set, only INSERT
- # codes NOT already active. Repeat-with-same-payload is a no-op.
- now = datetime.datetime.utcnow()
- current_codes = set(
- await _load_role_permission_codes(session, role.role_id)
- )
- new_set = set(new_codes)
-
- codes_to_drop = current_codes - new_set
- codes_to_add = new_set - current_codes
-
- if codes_to_drop:
- # Resolve to permission_ids then soft-delete by id pair.
- drop_ids = (
- await session.execute(
- select(Permissions.permission_id).where(
- Permissions.permission_code.in_(codes_to_drop),
- Permissions.is_deleted == 0,
- )
- )
- ).all()
- drop_id_values = [row[0] for row in drop_ids]
- await session.execute(
- update(RolePermissions)
- .where(
- RolePermissions.role_id == role.role_id,
- RolePermissions.permission_id.in_(drop_id_values),
- RolePermissions.is_deleted == 0,
- )
- .values(is_deleted=1, deleted_at=now)
- )
-
- if codes_to_add:
- add_ids = (
- await session.execute(
- select(Permissions.permission_id).where(
- Permissions.permission_code.in_(codes_to_add),
- Permissions.is_deleted == 0,
- )
- )
- ).all()
- if add_ids:
- await session.execute(
- insert(RolePermissions),
- [
- {"role_id": role.role_id, "permission_id": pid}
- for pid, in add_ids
- ],
- )
-
- await session.flush()
- final_codes = await _load_role_permission_codes(session, role.role_id)
- return _envelope(
- context.request_id, _role_payload(role, final_codes),
- )
-
-
-__all__ = [
- "SystemAdminContext",
- "router",
- "system_admin_context",
-]
+__all__ = ["SystemAdminContext", "router", "system_admin_context"]
diff --git a/backend/src/backend/api/platform/__init__.py b/backend/src/backend/api/platform/__init__.py
new file mode 100644
index 0000000..f9c8ea2
--- /dev/null
+++ b/backend/src/backend/api/platform/__init__.py
@@ -0,0 +1,92 @@
+"""系统级管理接口。
+
+中文导读:本模块管理全平台的用户、工作区、成员关系和角色权限。它使用
+``system_admin_context`` 进行平台管理员校验,因此不要求请求者先加入某个具体
+工作区;普通工作区内的业务接口则使用 ``request_context``。
+
+System-admin (platform-scope) endpoints for workspace & membership management.
+
+All routes under ``/api/v1/platform/*`` are gated by
+:func:`system_admin_context`, which requires the requester to hold a
+``Users.platform_role_id`` pointing to a ``Roles`` row whose
+``role_code == 'admin'``. Unlike ``backend.api.dependencies.request_context``,
+this dependency does NOT require an active workspace membership — system
+admins can manage workspaces before/without being a member of any.
+
+Endpoints
+---------
+
+Workspace CRUD::
+
+ GET /workspaces — list non-deleted workspaces
+ POST /workspaces — create a new workspace
+ GET /workspaces/{workspace_id} — single workspace (incl. disabled)
+ PATCH /workspaces/{workspace_id} — update editable fields
+ DELETE /workspaces/{workspace_id} — soft delete (cascades memberships)
+
+Workspace membership CRUD::
+
+ GET /workspaces/{workspace_id}/members — list active members
+ POST /workspaces/{workspace_id}/members — add a member
+ PATCH /workspaces/{workspace_id}/members/{user_id} — update role/status
+ DELETE /workspaces/{workspace_id}/members/{user_id} — remove a member
+
+Platform employee roster::
+
+ GET /employees — list all non-deleted users
+ POST /employees — create a user without workspace membership
+ PATCH /employees/{user_id} — update a user's profile, status or platform role
+ DELETE /employees/{user_id} — soft delete a user (cascades to workspace memberships)
+
+Role menu-permission management::
+
+ GET /roles — list platform roles with their permission_codes
+ GET /roles/{role_code}/permissions — one role's permission_codes
+ PATCH /roles/{role_code}/permissions — replace a role's permission set (diff-based)
+
+Invariants
+----------
+
+* Every workspace must always retain at least one active ``admin`` member.
+ This is enforced on member PATCH/DELETE AND on
+ ``PATCH /employees/{user_id}`` demotions, because workspace role is
+ inherited from ``users.platform_role_id`` and demoting a platform
+ admin cascades to all of their active memberships.
+* A system admin cannot remove their own workspace membership via
+ ``DELETE .../members/{self}``; the only escape is to delete the entire
+ workspace, which cascades membership soft-deletion.
+* ``DELETE /workspaces/{id}`` is allowed from any non-disabled status and
+ sets ``status='disabled'`` + ``is_deleted=1`` + ``deleted_at`` on the
+ workspace and every one of its active memberships.
+* The ``admin`` role must always keep ``system:view``.
+ menu permissions; non-admin roles may never hold ``system.*``
+ permissions. Menu permissions gate frontend rendering only — API
+ authorization always keys off ``role_code == 'admin'``.
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+from backend.api.platform._deps import (
+ SystemAdminContext,
+ system_admin_context,
+)
+from backend.api.platform.employees import router as employees_router
+from backend.api.platform.roles import router as roles_router
+from backend.api.platform.workspaces import router as workspaces_router
+
+# 聚合 router,所有 endpoint 都挂在 /api/v1/platform 下。每个子 router 自带
+# prefix="/api/v1/platform",这里 include_router 不加 prefix,路径保持不变。
+router = APIRouter()
+router.include_router(employees_router)
+router.include_router(workspaces_router)
+router.include_router(roles_router)
+
+# 保留 system_admin_context 的 re-export,供其他文件使用
+# (之前从 backend.api.platform 导出)。
+__all__ = [
+ "SystemAdminContext",
+ "router",
+ "system_admin_context",
+]
diff --git a/backend/src/backend/api/platform/_deps.py b/backend/src/backend/api/platform/_deps.py
new file mode 100644
index 0000000..5732e6e
--- /dev/null
+++ b/backend/src/backend/api/platform/_deps.py
@@ -0,0 +1,148 @@
+"""Shared dependencies & helpers for the platform admin API.
+Centralizes the ``system_admin_context`` gate plus cross-resource helpers so the
+resource modules (employees / workspaces / roles) stay focused on their endpoints.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from common.db.models import Roles, Users, WorkspaceMembers
+from common.ids import new_ulid
+from fastapi import Depends, HTTPException, Request, status
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.api.dependencies import current_user, database_session
+
+# ---------------------------------------------------------------------------
+# System-admin context dependency
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class SystemAdminContext:
+ """通过系统管理员校验后的上下文,只包含当前用户和请求追踪 ID。"""
+
+
+ request_id: str
+ user: Users
+ platform_role: Roles
+
+
+async def system_admin_context(
+ request: Request,
+ session: AsyncSession = Depends(database_session),
+) -> SystemAdminContext:
+ """验证当前用户是否为平台管理员,供 /api/v1/platform 下的路由依赖。"""
+ user = await current_user(request, session)
+ if user.platform_role_id is None:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "需要系统管理员权限",
+ )
+ platform_role = await session.scalar(
+ select(Roles).where(Roles.role_id == user.platform_role_id)
+ )
+ if platform_role is None or platform_role.role_code != "admin":
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "需要系统管理员权限",
+ )
+ request_id = request.headers.get("X-Request-ID") or new_ulid()
+ return SystemAdminContext(
+ request_id=request_id,
+ user=user,
+ platform_role=platform_role,
+ )
+
+
+async def _is_system_admin(session: AsyncSession, user: Users) -> bool:
+ """True if ``user`` holds the platform-scoped admin role.
+
+ Mirrors the check inside :func:`system_admin_context` so member-listing
+ endpoints can admit workspace members *or* system admins without pulling
+ in the full :class:`SystemAdminContext` (which 403s non-admins outright).
+ """
+ if user.platform_role_id is None:
+ return False
+ platform_role = await session.scalar(
+ select(Roles).where(Roles.role_id == user.platform_role_id)
+ )
+ return platform_role is not None and platform_role.role_code == "admin"
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+
+async def _load_role_by_code(session: AsyncSession, role_code: str) -> Roles:
+ role = await session.scalar(select(Roles).where(Roles.role_code == role_code))
+ if role is None:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ f"角色 {role_code} 不存在",
+ )
+ return role
+
+
+async def _count_active_admins(
+ session: AsyncSession,
+ workspace_id: str,
+ exclude_user_id: str | None = None,
+) -> int:
+ """Count active admin members of ``workspace_id``.
+
+ Pass ``exclude_user_id`` when checking "would X be the last admin?"
+ before mutating X.
+ """
+ admin_role = await _load_role_by_code(session, "admin")
+ stmt = (
+ select(func.count())
+ .select_from(WorkspaceMembers)
+ .where(
+ WorkspaceMembers.workspace_id == workspace_id,
+ WorkspaceMembers.role_id == admin_role.role_id,
+ WorkspaceMembers.member_status == "active",
+ WorkspaceMembers.is_deleted == 0,
+ )
+ )
+ if exclude_user_id is not None:
+ stmt = stmt.where(WorkspaceMembers.user_id != exclude_user_id)
+ return int(await session.scalar(stmt) or 0)
+
+
+async def _count_active_system_admins(
+ session: AsyncSession,
+ exclude_user_id: str | None = None,
+) -> int:
+ """Count active system admins across the platform.
+
+ Pass ``exclude_user_id`` when checking "would X be the last admin?"
+ before mutating X.
+ """
+ admin_role = await _load_role_by_code(session, "admin")
+ stmt = (
+ select(func.count())
+ .select_from(Users)
+ .where(
+ Users.status == "active",
+ Users.is_deleted == 0,
+ Users.platform_role_id == admin_role.role_id,
+ )
+ )
+ if exclude_user_id is not None:
+ stmt = stmt.where(Users.user_id != exclude_user_id)
+ return int(await session.scalar(stmt) or 0)
+
+
+def _envelope(request_id: str, data: Any, meta: dict[str, Any] | None = None) -> dict[str, Any]:
+ return {
+ "request_id": request_id,
+ "data": data,
+ "meta": meta or {},
+ }
+
+
diff --git a/backend/src/backend/api/platform/employees.py b/backend/src/backend/api/platform/employees.py
new file mode 100644
index 0000000..7c14131
--- /dev/null
+++ b/backend/src/backend/api/platform/employees.py
@@ -0,0 +1,343 @@
+"""Platform employee roster endpoints.
+CRUD for platform users (``/employees``). Role-code changes cascade to every active
+``workspace_members`` row because workspace role is inherited from the platform role.
+"""
+
+from __future__ import annotations
+
+import datetime
+from typing import Any, Literal
+
+from common.auth.passwords import hash_password
+from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
+from common.ids import new_ulid
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy import or_, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.api.dependencies import database_session
+from backend.api.platform._deps import (
+ SystemAdminContext,
+ _count_active_admins,
+ _count_active_system_admins,
+ _envelope,
+ _load_role_by_code,
+ system_admin_context,
+)
+
+router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
+
+
+# ---------------------------------------------------------------------------
+# Schemas
+# ---------------------------------------------------------------------------
+
+
+# 新建平台用户的请求体;创建用户不等同于把用户加入某个工作区。
+class PlatformEmployeeCreate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ username: str = Field(min_length=2, max_length=64)
+ display_name: str = Field(min_length=1, max_length=100)
+ email: str | None = Field(default=None, max_length=255)
+ password: str = Field(min_length=8, max_length=72)
+ role_code: Literal["admin", "developer"] | None = None
+
+
+# 修改平台用户资料、状态或平台角色的请求体。
+class PlatformEmployeeUpdate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ display_name: str | None = Field(default=None, min_length=1, max_length=100)
+ email: str | None = Field(default=None, max_length=255)
+ status: Literal["active", "disabled", "locked"] | None = None
+ role_code: Literal["admin", "developer"] | None = None
+
+
+# ---------------------------------------------------------------------------
+# Payload helpers
+# ---------------------------------------------------------------------------
+
+
+def platform_employee_payload(
+ user: Users,
+ role: Roles | None,
+) -> dict[str, Any]:
+ return {
+ "user_id": user.user_id,
+ "username": user.username,
+ "display_name": user.display_name,
+ "email": user.email,
+ "status": user.status,
+ "role_code": role.role_code if role is not None else None,
+ "role_name": role.role_name if role is not None else None,
+ "created_at": user.created_at.isoformat(),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Platform employee roster
+# ---------------------------------------------------------------------------
+
+
+# 列出整个平台的非删除用户;不局限于某一个工作区。
+@router.get("/employees")
+async def list_platform_employees(
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """List every non-soft-deleted platform user."""
+ rows = (
+ await session.execute(
+ select(Users, Roles)
+ .outerjoin(Roles, Roles.role_id == Users.platform_role_id)
+ .where(Users.is_deleted == 0)
+ .order_by(Users.created_at, Users.user_id)
+ )
+ ).all()
+ return _envelope(
+ context.request_id,
+ [platform_employee_payload(user, role) for user, role in rows],
+ {"count": len(rows)},
+ )
+
+
+# 创建平台用户;后续可再通过成员接口把该用户加入工作区。
+@router.post("/employees", status_code=status.HTTP_201_CREATED)
+async def create_platform_employee(
+ payload: PlatformEmployeeCreate,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Create a platform user without assigning workspace membership."""
+ username = payload.username.strip()
+ display_name = payload.display_name.strip()
+ duplicate_conditions = [Users.username == username]
+ if payload.email:
+ duplicate_conditions.append(Users.email == payload.email.strip())
+ duplicate = await session.scalar(
+ select(Users.user_id).where(or_(*duplicate_conditions))
+ )
+ if duplicate is not None:
+ raise HTTPException(status.HTTP_409_CONFLICT, "用户名或邮箱已存在")
+
+ new_role: Roles | None = None
+ if payload.role_code is not None:
+ new_role = await _load_role_by_code(session, payload.role_code)
+
+ user = Users(
+ user_id=new_ulid(),
+ username=username,
+ display_name=display_name,
+ email=payload.email.strip() if payload.email else None,
+ password_hash=hash_password(payload.password),
+ status="active",
+ platform_role_id=new_role.role_id if new_role is not None else None,
+ )
+ session.add(user)
+ await session.flush()
+ await session.refresh(user)
+ return _envelope(
+ context.request_id,
+ platform_employee_payload(user, new_role),
+ )
+
+
+# 更新平台用户资料、账号状态或平台角色,同时保护最少管理员等约束。
+@router.patch("/employees/{user_id}")
+async def update_platform_employee(
+ user_id: str,
+ payload: PlatformEmployeeUpdate,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Update a platform user's profile, status, or platform role.
+
+ Changing ``role_code`` cascades: every active ``workspace_members``
+ row of the user is rewritten to the new role (workspace role is
+ inherited from the platform role). Demoting admin → developer is
+ rejected with 409 when it would leave any workspace without an
+ active admin member, or the platform without an active system
+ admin. Self-demotion is always rejected.
+ """
+ user = await session.get(Users, user_id)
+ if user is None or user.is_deleted != 0:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
+
+ is_self = user_id == context.user.user_id
+ if (
+ is_self
+ and payload.status is not None
+ and payload.status != "active"
+ ):
+ raise HTTPException(status.HTTP_409_CONFLICT, "不能停用当前登录账号")
+
+ current_role: Roles | None = None
+ if user.platform_role_id is not None:
+ current_role = await session.scalar(
+ select(Roles).where(Roles.role_id == user.platform_role_id)
+ )
+ is_current_system_admin = (
+ user.status == "active"
+ and current_role is not None
+ and current_role.role_code == "admin"
+ )
+
+ new_role: Roles | None = None
+ if payload.role_code is not None:
+ new_role = await _load_role_by_code(session, payload.role_code)
+ next_status = payload.status if payload.status is not None else user.status
+
+ leaves_admin_pool = (
+ is_current_system_admin
+ and (
+ next_status != "active"
+ or (new_role is not None and new_role.role_code != "admin")
+ )
+ )
+ if leaves_admin_pool:
+ remaining = await _count_active_system_admins(
+ session, exclude_user_id=user_id,
+ )
+ if remaining == 0:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "platform 必须保留至少一个 active 系统管理员",
+ )
+
+ # Workspace-level last-admin guard for the demote path. The role_code
+ # sync below rewrites ``workspace_members.role_id`` for every active
+ # membership of this user, so demoting admin → developer would
+ # silently strip workspace admin coverage anywhere this user is the
+ # sole active admin member. ``update_member`` / ``remove_member``
+ # guard the same invariant via ``_count_active_admins``; this
+ # endpoint must too, now that it can change workspace roles.
+ demotes_admin = (
+ is_current_system_admin
+ and new_role is not None
+ and new_role.role_code != "admin"
+ )
+ if demotes_admin:
+ assert current_role is not None # implied by is_current_system_admin
+ admin_memberships = (
+ await session.execute(
+ select(WorkspaceMembers.workspace_id)
+ .where(
+ WorkspaceMembers.user_id == user_id,
+ WorkspaceMembers.role_id == current_role.role_id,
+ WorkspaceMembers.member_status == "active",
+ WorkspaceMembers.is_deleted == 0,
+ )
+ )
+ ).all()
+ orphaned: list[str] = []
+ for (ws_id,) in admin_memberships:
+ remaining_ws = await _count_active_admins(
+ session, ws_id, exclude_user_id=user_id,
+ )
+ if remaining_ws == 0:
+ orphaned.append(ws_id)
+ if orphaned:
+ codes = (
+ await session.execute(
+ select(Workspaces.workspace_code).where(
+ Workspaces.workspace_id.in_(orphaned)
+ )
+ )
+ ).all()
+ names = sorted(row[0] for row in codes)
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ f"以下 workspace 将失去唯一 active admin: {names};"
+ "请先在这些 workspace 中指定其他 admin,再降级该用户",
+ )
+
+ if is_self and new_role is not None and new_role.role_code != "admin":
+ raise HTTPException(status.HTTP_409_CONFLICT, "不能降级自身管理员角色")
+
+ if payload.display_name is not None:
+ user.display_name = payload.display_name.strip()
+ if payload.email is not None:
+ user.email = payload.email.strip() or None
+ if payload.status is not None:
+ user.status = payload.status
+ if new_role is not None:
+ user.platform_role_id = new_role.role_id
+ # Workspace role is always inherited from the platform role
+ # (§7.5/§7.6 cannot change it). Keep workspace_members.role_id
+ # in sync so downstream reads — `/me` workspaces[].role_code,
+ # load_active_membership, §7.7 DELETE last-admin guard —
+ # see the up-to-date role. Without this sync, a user demoted
+ # from admin → developer would still appear as admin in every
+ # workspace they belong to until they leave and re-join.
+ await session.execute(
+ update(WorkspaceMembers)
+ .where(
+ WorkspaceMembers.user_id == user.user_id,
+ WorkspaceMembers.is_deleted == 0,
+ )
+ .values(role_id=new_role.role_id)
+ )
+
+ await session.flush()
+ await session.refresh(user)
+
+ response_role: Roles | None = None
+ if user.platform_role_id is not None:
+ response_role = await session.scalar(
+ select(Roles).where(Roles.role_id == user.platform_role_id)
+ )
+ return _envelope(
+ context.request_id, platform_employee_payload(user, response_role),
+ )
+
+
+# 软删除平台用户,并级联标记其工作区成员关系为删除。
+@router.delete("/employees/{user_id}")
+async def delete_platform_employee(
+ user_id: str,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Soft delete a platform user and cascade-soft-delete workspace memberships."""
+ user = await session.get(Users, user_id)
+ if user is None or user.is_deleted != 0:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
+
+ if user_id == context.user.user_id:
+ raise HTTPException(status.HTTP_409_CONFLICT, "不能删除当前登录账号")
+
+ if user.status == "active" and user.platform_role_id is not None:
+ current_admin_role = await session.scalar(
+ select(Roles).where(Roles.role_id == user.platform_role_id)
+ )
+ if current_admin_role is not None and current_admin_role.role_code == "admin":
+ remaining = await _count_active_system_admins(
+ session, exclude_user_id=user_id,
+ )
+ if remaining == 0:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "platform 必须保留至少一个 active 系统管理员",
+ )
+
+ now = datetime.datetime.utcnow()
+ user.status = "disabled"
+ user.is_deleted = 1
+ user.deleted_at = now
+ await session.execute(
+ update(WorkspaceMembers)
+ .where(
+ WorkspaceMembers.user_id == user_id,
+ WorkspaceMembers.is_deleted == 0,
+ )
+ .values(is_deleted=1, deleted_at=now)
+ )
+ await session.flush()
+ return _envelope(
+ context.request_id,
+ {"user_id": user_id, "deleted": True},
+ )
+
+
diff --git a/backend/src/backend/api/platform/roles.py b/backend/src/backend/api/platform/roles.py
new file mode 100644
index 0000000..0d5379a
--- /dev/null
+++ b/backend/src/backend/api/platform/roles.py
@@ -0,0 +1,499 @@
+"""Platform role & permission management endpoints.
+Seven endpoints: role list/CRUD, per-role permission get/patch, and the permission
+catalog; ``_apply_role_permission_set`` is shared by PATCH /roles/{code}/permissions and POST /roles.
+"""
+
+from __future__ import annotations
+
+import datetime
+import re
+from typing import Any
+
+from common.db.models import (
+ Permissions,
+ RolePermissions,
+ Roles,
+ Users,
+ WorkspaceMembers,
+)
+from common.ids import new_ulid
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+from sqlalchemy import func, insert, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.api.dependencies import database_session
+from backend.api.platform._deps import (
+ SystemAdminContext,
+ _envelope,
+ system_admin_context,
+)
+
+router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
+
+RESERVED_PLATFORM_ROLE_CODES = frozenset({"admin", "developer"})
+ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{1,63}$")
+
+class RolePermissionsPatch(BaseModel):
+ """Replace a platform role's permission set wholesale.
+
+ Empty list is allowed (revokes all permissions) for non-`admin`
+ roles. The PATCH endpoint rejects emptying an `admin` role of its
+ system.* permissions; see ``patch_role_permissions`` for the
+ load-bearing guard order.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ permission_codes: list[str] = Field(default_factory=list, max_length=64)
+
+class RoleCreate(BaseModel):
+ """Create a platform role.
+
+ ``role_code`` must not collide with the reserved built-in codes
+ (``admin`` / ``developer``); the validator rejects those with 422.
+ ``permission_codes`` is optional — an empty list means the role
+ starts with no menu permissions.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ role_code: str = Field(min_length=2, max_length=64)
+ role_name: str = Field(min_length=1, max_length=100)
+ description: str | None = Field(default=None, max_length=500)
+ permission_codes: list[str] = Field(default_factory=list, max_length=64)
+
+ @field_validator("role_code")
+ @classmethod
+ def _validate_role_code(cls, value: str) -> str:
+ if value in RESERVED_PLATFORM_ROLE_CODES:
+ raise ValueError(f"role_code {value!r} 是预留字")
+ if not ROLE_CODE_PATTERN.match(value):
+ raise ValueError("role_code 必须以小写字母开头,仅含小写字母/数字/下划线/连字符")
+ return value
+
+class RoleUpdate(BaseModel):
+ """Update a platform role's ``role_name`` / ``description`` only.
+
+ ``role_code`` is intentionally absent — it is the URL key and is
+ backed by a unique index; ``extra="forbid"`` rejects any attempt to
+ send it. ``description`` accepts an explicit ``null`` (clear the
+ value); omitting it leaves it unchanged.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ role_name: str | None = Field(default=None, min_length=1, max_length=100)
+ description: str | None = Field(default=None, max_length=500)
+
+async def _load_platform_role_by_code(
+ session: AsyncSession, role_code: str
+) -> Roles:
+ """Load a platform-scoped role by code; 404 if missing or not platform-scope."""
+ role = await session.scalar(
+ select(Roles).where(
+ Roles.role_code == role_code, Roles.is_deleted == 0,
+ )
+ )
+ if role is None or role.role_scope != "platform":
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND, f"platform 角色 {role_code} 不存在",
+ )
+ return role
+
+async def _load_role_permission_codes(
+ session: AsyncSession, role_id: str
+) -> list[str]:
+ """Return the active permission_codes for a role, ordered by code."""
+ rows = (
+ await session.execute(
+ select(Permissions.permission_code)
+ .join(
+ RolePermissions,
+ RolePermissions.permission_id == Permissions.permission_id,
+ )
+ .where(
+ RolePermissions.role_id == role_id,
+ RolePermissions.is_deleted == 0,
+ Permissions.is_deleted == 0,
+ )
+ .order_by(Permissions.permission_code)
+ )
+ ).all()
+ return [row[0] for row in rows]
+
+def _role_payload(role: Roles, permission_codes: list[str]) -> dict[str, Any]:
+ return {
+ "role_id": role.role_id,
+ "role_code": role.role_code,
+ "role_name": role.role_name,
+ "is_builtin": bool(role.is_builtin),
+ "permission_codes": permission_codes,
+ # 加法字段:POST/PATCH/GET 都自动带上 description,便于前端展示。
+ "description": role.description,
+ }
+
+@router.get("/roles")
+async def list_platform_roles(
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """List every platform-scoped role with its current permission_codes."""
+ roles = (
+ await session.scalars(
+ select(Roles)
+ .where(Roles.role_scope == "platform", Roles.is_deleted == 0)
+ .order_by(Roles.role_code)
+ )
+ ).all()
+ payload = []
+ for role in roles:
+ codes = await _load_role_permission_codes(session, role.role_id)
+ payload.append(_role_payload(role, codes))
+ return _envelope(
+ context.request_id, payload, {"count": len(payload)},
+ )
+
+@router.get("/roles/{role_code}/permissions")
+async def get_role_permissions(
+ role_code: str,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Return one platform role's permission_codes."""
+ role = await _load_platform_role_by_code(session, role_code)
+ codes = await _load_role_permission_codes(session, role.role_id)
+ return _envelope(
+ context.request_id, _role_payload(role, codes),
+ )
+
+async def _apply_role_permission_set(
+ session: AsyncSession, role: Roles, codes: list[str]
+) -> list[str]:
+ """Validate ``codes`` then replace the role's permission set wholesale.
+
+ Shared by PATCH /roles/{role_code}/permissions and POST /roles so the
+ guard order is identical no matter the entry point:
+
+ 1. Admin: must keep ``system:view`` → 409 (menu perms never gate
+ API access; auth keys off ``role_code == 'admin'``).
+ 2. Non-admin: ``system.*`` codes → 422.
+ 3. Unknown codes → 422.
+ 4. Diff-based soft-delete + insert — the ``(role_id, permission_id)``
+ PK keeps soft-deleted rows, so delete-all/insert-all would
+ IntegrityError. Repeat-with-same-set is a no-op.
+
+ Returns the role's final permission_codes (after flush).
+ """
+ new_codes = list(dict.fromkeys(codes))
+
+ if role.role_code == "admin":
+ keeps_admin_entry = "system:view" in new_codes
+ if not keeps_admin_entry:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "admin 角色必须保留 system:view 权限",
+ )
+ else:
+ # Menu permissions are a frontend-display signal only — backend
+ # authorization keeps keying off role_code == "admin". Letting a
+ # non-admin role hold system.* permissions would render the
+ # system-admin entry in the developer's UI while every
+ # /api/v1/platform/* call still returns 403. Reject with 422 so
+ # the failure is unambiguous about *what* the input violated.
+ leaked_system = [
+ code for code in new_codes if code.startswith("system:")
+ ]
+ if leaked_system:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ f"非 admin 角色不能拥有 system.* 权限: {leaked_system}",
+ )
+
+ # 3. Validate every requested permission_code exists and is live.
+ if new_codes:
+ rows = (
+ await session.execute(
+ select(Permissions.permission_code).where(
+ Permissions.permission_code.in_(new_codes),
+ Permissions.is_deleted == 0,
+ )
+ )
+ ).all()
+ found = {row[0] for row in rows}
+ missing = [code for code in new_codes if code not in found]
+ if missing:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ f"未知的 permission_code: {missing}",
+ )
+
+ # 4. Write: diff-based soft-delete + insert.
+ # The (role_id, permission_id) PRIMARY KEY still occupies the slot
+ # of soft-deleted rows, so a "delete-all then insert-all" approach
+ # would IntegrityError on any code that was already linked.
+ # Instead: only soft-delete codes NOT in the new set, only INSERT
+ # codes NOT already active. Repeat-with-same-payload is a no-op.
+ now = datetime.datetime.utcnow()
+ current_codes = set(
+ await _load_role_permission_codes(session, role.role_id)
+ )
+ new_set = set(new_codes)
+
+ codes_to_drop = current_codes - new_set
+ codes_to_add = new_set - current_codes
+
+ if codes_to_drop:
+ # Resolve to permission_ids then soft-delete by id pair.
+ drop_ids = (
+ await session.execute(
+ select(Permissions.permission_id).where(
+ Permissions.permission_code.in_(codes_to_drop),
+ Permissions.is_deleted == 0,
+ )
+ )
+ ).all()
+ drop_id_values = [row[0] for row in drop_ids]
+ await session.execute(
+ update(RolePermissions)
+ .where(
+ RolePermissions.role_id == role.role_id,
+ RolePermissions.permission_id.in_(drop_id_values),
+ RolePermissions.is_deleted == 0,
+ )
+ .values(is_deleted=1, deleted_at=now)
+ )
+
+ if codes_to_add:
+ add_ids = (
+ await session.execute(
+ select(Permissions.permission_id).where(
+ Permissions.permission_code.in_(codes_to_add),
+ Permissions.is_deleted == 0,
+ )
+ )
+ ).all()
+ if add_ids:
+ await session.execute(
+ insert(RolePermissions),
+ [
+ {"role_id": role.role_id, "permission_id": pid}
+ for pid, in add_ids
+ ],
+ )
+
+ await session.flush()
+ return await _load_role_permission_codes(session, role.role_id)
+
+@router.patch("/roles/{role_code}/permissions")
+async def patch_role_permissions(
+ role_code: str,
+ payload: RolePermissionsPatch,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Replace a platform role's permission set wholesale.
+
+ The guard order and the diff-based write live in
+ :func:`_apply_role_permission_set`, shared with POST /roles so both
+ entry points validate identically.
+ """
+ role = await _load_platform_role_by_code(session, role_code)
+ final_codes = await _apply_role_permission_set(
+ session, role, payload.permission_codes,
+ )
+ return _envelope(
+ context.request_id, _role_payload(role, final_codes),
+ )
+
+@router.post("/roles", status_code=status.HTTP_201_CREATED)
+async def create_platform_role(
+ payload: RoleCreate,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Create a platform role.
+
+ - ``role_code`` validated by the schema (reserved codes + pattern →
+ 422) and must not collide with ``uk_roles_code`` → 409 (incl.
+ soft-deleted rows that still occupy the index slot).
+ - Always ``role_scope='platform'``, ``is_builtin=0``.
+ - Optional ``permission_codes``: empty → no menu permissions; non-empty
+ applies the same guard order as PATCH via
+ :func:`_apply_role_permission_set`.
+ """
+ role_code = payload.role_code
+ duplicate = await session.scalar(
+ select(Roles.role_id).where(Roles.role_code == role_code)
+ )
+ if duplicate is not None:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT, f"role_code {role_code!r} 已存在",
+ )
+
+ role = Roles(
+ role_id=new_ulid(),
+ role_code=role_code,
+ role_name=payload.role_name.strip(),
+ role_scope="platform",
+ is_builtin=0,
+ description=payload.description,
+ )
+ session.add(role)
+ await session.flush()
+
+ final_codes: list[str] = []
+ if payload.permission_codes:
+ final_codes = await _apply_role_permission_set(
+ session, role, payload.permission_codes,
+ )
+
+ await session.refresh(role)
+ return _envelope(
+ context.request_id, _role_payload(role, final_codes),
+ )
+
+@router.patch("/roles/{role_code}")
+async def update_platform_role(
+ role_code: str,
+ payload: RoleUpdate,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Update a platform role's ``role_name`` / ``description``.
+
+ - ``role_code`` cannot change: it is the URL key (unique
+ ``uk_roles_code`` index) and the schema forbids sending it → 422.
+ - Built-in roles (``is_builtin=1``) keep ``role_name`` locked → 409;
+ ``description`` may still be edited.
+ - ``description`` accepts an explicit ``null`` (clear); omission
+ leaves it unchanged.
+ - ``updated_at`` refreshed by the DB's ``ON UPDATE
+ CURRENT_TIMESTAMP(3)`` server default.
+ """
+ role = await _load_platform_role_by_code(session, role_code)
+
+ if (
+ role.is_builtin == 1
+ and payload.role_name is not None
+ and payload.role_name != role.role_name
+ ):
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "内置角色 role_name 不可改",
+ )
+
+ if payload.role_name is not None:
+ role.role_name = payload.role_name.strip()
+ # 显式传 null → 清空 description;字段省略 → 不改。
+ # 用 model_fields_set 区分 "省略" 与 "显式 null",因为两者在 Pydantic
+ # 里都解析为 None。
+ if "description" in payload.model_fields_set:
+ role.description = payload.description
+
+ await session.flush()
+ await session.refresh(role)
+ codes = await _load_role_permission_codes(session, role.role_id)
+ return _envelope(
+ context.request_id, _role_payload(role, codes),
+ )
+
+@router.delete("/roles/{role_code}")
+async def delete_platform_role(
+ role_code: str,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Soft-delete a platform role.
+
+ Guards:
+ - Built-in roles (``is_builtin=1``) → 409.
+ - Active ``users.platform_role_id`` reference → 409 (migrate users first).
+ - Active ``workspace_members.role_id`` reference → 409 (same ``Roles``
+ row — a hard orphan would leave it dangling).
+ """
+ role = await _load_platform_role_by_code(session, role_code)
+
+ if role.is_builtin == 1:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT, "内置角色不可删除",
+ )
+
+ user_refs = int(
+ await session.scalar(
+ select(func.count())
+ .select_from(Users)
+ .where(
+ Users.platform_role_id == role.role_id,
+ Users.is_deleted == 0,
+ )
+ )
+ or 0
+ )
+ if user_refs > 0:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ f"仍有 {user_refs} 个用户引用此角色",
+ )
+
+ member_refs = int(
+ await session.scalar(
+ select(func.count())
+ .select_from(WorkspaceMembers)
+ .where(
+ WorkspaceMembers.role_id == role.role_id,
+ WorkspaceMembers.is_deleted == 0,
+ )
+ )
+ or 0
+ )
+ if member_refs > 0:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ f"仍有 {member_refs} 个 workspace 成员引用此角色",
+ )
+
+ role.is_deleted = 1
+ role.deleted_at = datetime.datetime.utcnow()
+ await session.flush()
+ return _envelope(
+ context.request_id,
+ {"role_code": role_code, "deleted": True},
+ )
+
+@router.get("/permissions")
+async def list_platform_permissions(
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """List every active permission, ordered by module_code + permission_code.
+
+ Returns ``[{permission_code, permission_name, module_code,
+ description}, ...]`` for the role-management UI. Includes
+ ``system:role:view`` which the admin role holds by default.
+ """
+ rows = (
+ await session.execute(
+ select(
+ Permissions.permission_code,
+ Permissions.permission_name,
+ Permissions.module_code,
+ Permissions.description,
+ )
+ .where(Permissions.is_deleted == 0)
+ .order_by(Permissions.module_code, Permissions.permission_code)
+ )
+ ).all()
+ payload = [
+ {
+ "permission_code": code,
+ "permission_name": name,
+ "module_code": module,
+ "description": description,
+ }
+ for code, name, module, description in rows
+ ]
+ return _envelope(
+ context.request_id, payload, {"count": len(payload)},
+ )
+
diff --git a/backend/src/backend/api/platform/workspaces.py b/backend/src/backend/api/platform/workspaces.py
new file mode 100644
index 0000000..5cda1d3
--- /dev/null
+++ b/backend/src/backend/api/platform/workspaces.py
@@ -0,0 +1,497 @@
+"""Workspace & membership CRUD endpoints.
+Five workspace endpoints plus five membership endpoints, gated by
+``system_admin_context`` (except ``GET .../members``, which also admits workspace
+members). Last-admin guards and soft-delete cascades live here.
+"""
+
+from __future__ import annotations
+
+import datetime
+import re
+from typing import Any, Literal
+
+from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
+from common.ids import new_ulid
+from fastapi import APIRouter, Depends, HTTPException, Request, status
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.api.dependencies import current_user, database_session
+from backend.api.platform._deps import (
+ SystemAdminContext,
+ _count_active_admins,
+ _envelope,
+ _is_system_admin,
+ _load_role_by_code,
+ system_admin_context,
+)
+
+router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
+
+WORKSPACE_CODE_PATTERN = re.compile(r"^[a-z0-9-]{3,32}$")
+LIST_PAGE_SIZE = 100
+
+WORKSPACE_EDITABLE_STATUS = ("active", "archived")
+MEMBER_ROLE_CODES = ("admin", "developer")
+MEMBER_STATUS_VALUES = ("active", "disabled", "locked")
+
+# 创建工作区时前端提交的请求体;禁止未声明字段。
+class WorkspaceCreate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ workspace_code: str = Field(min_length=3, max_length=32)
+ workspace_name: str = Field(min_length=1, max_length=150)
+ quota_bytes: int = Field(default=0, ge=0)
+ description: str | None = Field(default=None, max_length=1000)
+
+# 编辑工作区时允许修改的字段;禁用操作必须走删除接口而不是直接传状态。
+class WorkspaceUpdate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ workspace_name: str | None = Field(default=None, min_length=1, max_length=150)
+ quota_bytes: int | None = Field(default=None, ge=0)
+ description: str | None = Field(default=None, max_length=1000)
+ # 'disabled' is rejected here on purpose — soft delete must go through DELETE.
+ status: Literal["active", "archived"] | None = None
+
+class MemberCreate(BaseModel):
+ """Add a user to a workspace. Role is inherited from the user's
+ platform role (Users.platform_role_id) — not set here."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ user_id: str = Field(min_length=26, max_length=26)
+
+class MemberUpdate(BaseModel):
+ """Update a workspace membership's status. Role cannot be changed
+ via this endpoint — workspace role is always inherited from the
+ user's platform role. To change a member's role, PATCH
+ /platform/employees/{user_id} instead."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ member_status: Literal["active", "disabled", "locked"] | None = None
+
+def workspace_payload(workspace: Workspaces) -> dict[str, Any]:
+ return {
+ "workspace_id": workspace.workspace_id,
+ "workspace_code": workspace.workspace_code,
+ "workspace_name": workspace.workspace_name,
+ "active_root_uri": workspace.active_root_uri,
+ "quota_bytes": workspace.quota_bytes,
+ "status": workspace.status,
+ "description": workspace.description,
+ "created_by": workspace.created_by,
+ "created_at": workspace.created_at.isoformat(),
+ "updated_at": (
+ workspace.updated_at.isoformat() if workspace.updated_at else None
+ ),
+ }
+
+def member_payload(
+ user: Users,
+ role: Roles,
+ membership: WorkspaceMembers,
+) -> dict[str, Any]:
+ return {
+ "user_id": user.user_id,
+ "username": user.username,
+ "display_name": user.display_name,
+ "email": user.email,
+ "user_status": user.status,
+ "role_code": role.role_code,
+ "role_name": role.role_name,
+ "member_status": membership.member_status,
+ "joined_at": membership.joined_at.isoformat(),
+ }
+
+async def _load_workspace(session: AsyncSession, workspace_id: str) -> Workspaces:
+ workspace = await session.get(Workspaces, workspace_id)
+ if workspace is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "workspace 不存在")
+ return workspace
+
+@router.get("/workspaces")
+async def list_workspaces(
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """List active/archived workspaces. Soft-deleted rows are filtered out.
+
+ Silent ``pageSize=100`` cap — YAGNI on real pagination until needed.
+ """
+ rows = (
+ await session.execute(
+ select(Workspaces)
+ .where(
+ Workspaces.status != "disabled",
+ Workspaces.is_deleted == 0,
+ )
+ .order_by(Workspaces.created_at, Workspaces.workspace_id)
+ .limit(LIST_PAGE_SIZE)
+ )
+ ).scalars().all()
+ return _envelope(
+ context.request_id,
+ [workspace_payload(w) for w in rows],
+ {"count": len(rows), "page_size": LIST_PAGE_SIZE},
+ )
+
+@router.post("/workspaces", status_code=status.HTTP_201_CREATED)
+async def create_workspace(
+ payload: WorkspaceCreate,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Create a workspace and auto-join the creator as an admin member."""
+ if not WORKSPACE_CODE_PATTERN.fullmatch(payload.workspace_code):
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "workspace_code 必须匹配 ^[a-z0-9-]{3,32}$",
+ )
+ duplicate = await session.scalar(
+ select(Workspaces.workspace_id).where(
+ Workspaces.workspace_code == payload.workspace_code,
+ )
+ )
+ if duplicate is not None:
+ raise HTTPException(status.HTTP_409_CONFLICT, "workspace_code 已存在")
+
+ admin_role = await _load_role_by_code(session, "admin")
+ workspace_id = new_ulid()
+ workspace = Workspaces(
+ workspace_id=workspace_id,
+ workspace_code=payload.workspace_code,
+ workspace_name=payload.workspace_name,
+ active_root_uri=f"s3://workspaces/{workspace_id}/",
+ quota_bytes=payload.quota_bytes,
+ status="active",
+ created_by=context.user.user_id,
+ description=payload.description,
+ )
+ session.add(workspace)
+ session.add(
+ WorkspaceMembers(
+ workspace_id=workspace_id,
+ user_id=context.user.user_id,
+ role_id=admin_role.role_id,
+ member_status="active",
+ )
+ )
+ await session.flush()
+ await session.refresh(workspace)
+ return _envelope(context.request_id, workspace_payload(workspace))
+
+@router.get("/workspaces/{workspace_id}")
+async def get_workspace(
+ workspace_id: str,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Fetch a single workspace — even soft-deleted ones are reachable."""
+ workspace = await _load_workspace(session, workspace_id)
+ return _envelope(context.request_id, workspace_payload(workspace))
+
+@router.patch("/workspaces/{workspace_id}")
+async def update_workspace(
+ workspace_id: str,
+ payload: WorkspaceUpdate,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Patch editable workspace fields. ``status='disabled'`` is rejected."""
+ workspace = await _load_workspace(session, workspace_id)
+ if workspace.status == "disabled":
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "workspace 已删除,无法修改",
+ )
+ if payload.workspace_name is not None:
+ workspace.workspace_name = payload.workspace_name.strip()
+ if payload.quota_bytes is not None:
+ workspace.quota_bytes = payload.quota_bytes
+ if payload.description is not None:
+ workspace.description = payload.description
+ if payload.status is not None:
+ workspace.status = payload.status
+ await session.flush()
+ await session.refresh(workspace)
+ return _envelope(context.request_id, workspace_payload(workspace))
+
+@router.delete("/workspaces/{workspace_id}")
+async def delete_workspace(
+ workspace_id: str,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Soft-delete a workspace and cascade-soft-delete its memberships.
+
+ Allowed from any non-disabled status (active or archived). The
+ membership cascade is what lets system admins leave a workspace —
+ there is no per-member DELETE escape for self-removal.
+ """
+ workspace = await _load_workspace(session, workspace_id)
+ if workspace.status == "disabled":
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "workspace 已被删除",
+ )
+ now = datetime.datetime.utcnow()
+ workspace.status = "disabled"
+ workspace.is_deleted = 1
+ workspace.deleted_at = now
+ await session.execute(
+ update(WorkspaceMembers)
+ .where(
+ WorkspaceMembers.workspace_id == workspace_id,
+ WorkspaceMembers.is_deleted == 0,
+ )
+ .values(is_deleted=1, deleted_at=now)
+ )
+ await session.flush()
+ await session.refresh(workspace)
+ return _envelope(context.request_id, workspace_payload(workspace))
+
+@router.get("/workspaces/{workspace_id}/members")
+async def list_members(
+ workspace_id: str,
+ request: Request,
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """List active and historical (non-soft-deleted) members of a workspace.
+
+ Accessible to system admins (any workspace) and to active members of the
+ workspace itself. The script explorer calls this to seed the per-owner
+ directory-tree groups for non-admin users; visibility filters on the
+ scripts/data-resources endpoints still keep each peer's private content
+ hidden, so this only exposes membership (names), not private files.
+ """
+ user = await current_user(request, session)
+ is_system_admin = await _is_system_admin(session, user)
+ if not is_system_admin:
+ membership = await session.scalar(
+ select(WorkspaceMembers).where(
+ WorkspaceMembers.workspace_id == workspace_id,
+ WorkspaceMembers.user_id == user.user_id,
+ WorkspaceMembers.is_deleted == 0,
+ WorkspaceMembers.member_status == "active",
+ )
+ )
+ if membership is None:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "需要系统管理员或该工作区成员权限",
+ )
+ await _load_workspace(session, workspace_id)
+ rows = (
+ await session.execute(
+ select(Users, Roles, WorkspaceMembers)
+ .join(
+ WorkspaceMembers,
+ WorkspaceMembers.user_id == Users.user_id,
+ )
+ .join(Roles, Roles.role_id == WorkspaceMembers.role_id)
+ .where(
+ WorkspaceMembers.workspace_id == workspace_id,
+ WorkspaceMembers.is_deleted == 0,
+ )
+ .order_by(WorkspaceMembers.joined_at, Users.user_id)
+ .limit(LIST_PAGE_SIZE)
+ )
+ ).all()
+ request_id = request.headers.get("X-Request-ID") or new_ulid()
+ return _envelope(
+ request_id,
+ [member_payload(u, r, m) for u, r, m in rows],
+ {"count": len(rows), "page_size": LIST_PAGE_SIZE},
+ )
+
+@router.post(
+ "/workspaces/{workspace_id}/members",
+ status_code=status.HTTP_201_CREATED,
+)
+async def add_member(
+ workspace_id: str,
+ payload: MemberCreate,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Add a user to a workspace. The new row starts with member_status='active'.
+
+ The role is inherited from the target user's ``platform_role_id``;
+ the request body does NOT take a ``role_code``. To change a member's
+ role, PATCH ``/api/v1/platform/employees/{user_id}`` instead.
+ """
+ await _load_workspace(session, workspace_id)
+ user = await session.get(Users, payload.user_id)
+ if user is None or user.is_deleted != 0:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
+ if user.status != "active":
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ f"用户状态为 {user.status},无法加入 workspace",
+ )
+ if user.platform_role_id is None:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "目标用户尚未分配平台角色,无法加入 workspace;"
+ "请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code",
+ )
+ role = await session.scalar(
+ select(Roles).where(
+ Roles.role_id == user.platform_role_id,
+ Roles.is_deleted == 0,
+ )
+ )
+ if role is None:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "用户的平台角色行不存在或已被删除",
+ )
+ # ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK,
+ # 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行,
+ # 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行:
+ # 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK.
+ existing = await session.scalar(
+ select(WorkspaceMembers).where(
+ WorkspaceMembers.workspace_id == workspace_id,
+ WorkspaceMembers.user_id == payload.user_id,
+ )
+ )
+ if existing is not None:
+ if existing.is_deleted == 0:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "用户已是该 workspace 成员;workspace 角色继承自平台角色,"
+ "要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code",
+ )
+ # 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承
+ # 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉
+ # ``deleted_at`` 标记本轮已不在软删状态.
+ existing.is_deleted = 0
+ existing.deleted_at = None
+ existing.role_id = role.role_id
+ existing.member_status = "active"
+ await session.flush()
+ await session.refresh(existing)
+ return _envelope(
+ context.request_id, member_payload(user, role, existing),
+ )
+ membership = WorkspaceMembers(
+ workspace_id=workspace_id,
+ user_id=payload.user_id,
+ role_id=role.role_id,
+ member_status="active",
+ )
+ session.add(membership)
+ await session.flush()
+ await session.refresh(membership)
+ return _envelope(context.request_id, member_payload(user, role, membership))
+
+@router.patch("/workspaces/{workspace_id}/members/{user_id}")
+async def update_member(
+ workspace_id: str,
+ user_id: str,
+ payload: MemberUpdate,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Update a workspace membership's status. Role is not editable here.
+
+ Workspace role is always inherited from the user's platform role
+ (``Users.platform_role_id``). To change role, PATCH
+ ``/api/v1/platform/employees/{user_id}`` instead.
+
+ Last-admin guard still applies to ``member_status`` changes: setting
+ the only active admin to ``disabled``/``locked`` would leave the
+ workspace without admin coverage.
+ """
+ await _load_workspace(session, workspace_id)
+ row = (
+ await session.execute(
+ select(Users, Roles, WorkspaceMembers)
+ .join(
+ WorkspaceMembers,
+ WorkspaceMembers.user_id == Users.user_id,
+ )
+ .join(Roles, Roles.role_id == WorkspaceMembers.role_id)
+ .where(
+ WorkspaceMembers.workspace_id == workspace_id,
+ WorkspaceMembers.user_id == user_id,
+ WorkspaceMembers.is_deleted == 0,
+ )
+ )
+ ).first()
+ if row is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
+ user, role, membership = row
+
+ if payload.member_status is not None and payload.member_status != membership.member_status:
+ if (
+ role.role_code == "admin"
+ and payload.member_status != "active"
+ ):
+ remaining = await _count_active_admins(
+ session, workspace_id, exclude_user_id=user_id,
+ )
+ if remaining == 0:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "workspace 必须保留至少一个 admin",
+ )
+ membership.member_status = payload.member_status
+
+ await session.flush()
+ await session.refresh(membership)
+ return _envelope(context.request_id, member_payload(user, role, membership))
+
+@router.delete("/workspaces/{workspace_id}/members/{user_id}")
+async def remove_member(
+ workspace_id: str,
+ user_id: str,
+ context: SystemAdminContext = Depends(system_admin_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ """Soft-delete a workspace membership.
+
+ System admins cannot remove themselves — the only escape is to delete
+ the entire workspace, which cascades membership soft-deletion.
+ """
+ await _load_workspace(session, workspace_id)
+ if user_id == context.user.user_id:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace",
+ )
+ row = (
+ await session.execute(
+ select(Roles, WorkspaceMembers)
+ .join(Roles, Roles.role_id == WorkspaceMembers.role_id)
+ .where(
+ WorkspaceMembers.workspace_id == workspace_id,
+ WorkspaceMembers.user_id == user_id,
+ WorkspaceMembers.is_deleted == 0,
+ )
+ )
+ ).first()
+ if row is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
+ role, membership = row
+ if role.role_code == "admin" and membership.member_status == "active":
+ remaining = await _count_active_admins(
+ session, workspace_id, exclude_user_id=user_id,
+ )
+ if remaining == 0:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "workspace 必须保留至少一个 admin",
+ )
+ membership.is_deleted = 1
+ membership.deleted_at = datetime.datetime.utcnow()
+ await session.flush()
+ return _envelope(
+ context.request_id,
+ {"workspace_id": workspace_id, "user_id": user_id, "removed": True},
+ )
+
diff --git a/migrations/versions/j4k5l6m7n8o9_add_system_role_view_permission.py b/migrations/versions/j4k5l6m7n8o9_add_system_role_view_permission.py
new file mode 100644
index 0000000..cd094b3
--- /dev/null
+++ b/migrations/versions/j4k5l6m7n8o9_add_system_role_view_permission.py
@@ -0,0 +1,119 @@
+"""add system:role:view permission + grant it to admin
+
+Revision ID: j4k5l6m7n8o9
+Revises: e1f2a3b4c5d6
+Create Date: 2026-08-25
+"""
+
+import hashlib
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "j4k5l6m7n8o9"
+down_revision: str | Sequence[str] | None = "e1f2a3b4c5d6"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+# ── seed constants ───────────────────────────────────────────────
+ADMIN_ROLE_ID = "0000000000000000000000000A"
+
+PERMISSION_CODE = "system:role:view"
+PERMISSION_NAME = "查看角色管理"
+MODULE_CODE = "system"
+PERMISSION_DESCRIPTION = "查看角色管理与权限配置"
+
+CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+
+def _deterministic_permission_id(code: str) -> str:
+ """Stable 26-char ULID-shaped id derived from permission_code.
+
+ Mirrors ``e1f2a3b4c5d6_rebuild_baseline._deterministic_permission_id``
+ so the id for a given code matches what the baseline would derive.
+ """
+ digest = hashlib.sha256(
+ f"model-platform-permission-v1:{code}".encode()
+ ).digest()
+ value = int.from_bytes(b"\x00" * 6 + digest[:10], byteorder="big")
+ encoded = ["0"] * 26
+ for index in range(25, -1, -1):
+ encoded[index] = CROCKFORD_BASE32[value & 31]
+ value >>= 5
+ return "".join(encoded)
+
+def upgrade() -> None:
+ """Add system:role:view permission and grant it to admin only."""
+ permission_id = _deterministic_permission_id(PERMISSION_CODE)
+
+ permissions_table = sa.table(
+ "permissions",
+ sa.column("permission_id", sa.CHAR(26)),
+ sa.column("permission_code", sa.String(128)),
+ sa.column("permission_name", sa.String(100)),
+ sa.column("module_code", sa.String(64)),
+ sa.column("description", sa.String(500)),
+ )
+ op.bulk_insert(
+ permissions_table,
+ [
+ {
+ "permission_id": permission_id,
+ "permission_code": PERMISSION_CODE,
+ "permission_name": PERMISSION_NAME,
+ "module_code": MODULE_CODE,
+ "description": PERMISSION_DESCRIPTION,
+ }
+ ],
+ )
+
+ # admin 持有全部 system:*(沿用基线约定);developer 不授予 system:*.
+ role_permissions_table = sa.table(
+ "role_permissions",
+ sa.column("role_id", sa.CHAR(26)),
+ sa.column("permission_id", sa.CHAR(26)),
+ )
+ op.bulk_insert(
+ role_permissions_table,
+ [
+ {
+ "role_id": ADMIN_ROLE_ID,
+ "permission_id": permission_id,
+ }
+ ],
+ )
+
+def downgrade() -> None:
+ """Remove admin's system:role:view grant and soft-delete the permission."""
+ permission_id = _deterministic_permission_id(PERMISSION_CODE)
+
+ # 软删 role_permissions 关联行(与后端 soft-delete 约定一致).
+ role_permissions_table = sa.table(
+ "role_permissions",
+ sa.column("role_id", sa.CHAR(26)),
+ sa.column("permission_id", sa.CHAR(26)),
+ sa.column("is_deleted", sa.Integer),
+ sa.column("deleted_at", sa.DateTime),
+ )
+ op.execute(
+ role_permissions_table.update()
+ .where(role_permissions_table.c.role_id == ADMIN_ROLE_ID)
+ .where(role_permissions_table.c.permission_id == permission_id)
+ .where(role_permissions_table.c.is_deleted == 0)
+ .values(is_deleted=1, deleted_at=sa.text("CURRENT_TIMESTAMP(3)"))
+ )
+
+ # 软删 permissions 行(基线表含 is_deleted,优先软删除).
+ permissions_table = sa.table(
+ "permissions",
+ sa.column("permission_id", sa.CHAR(26)),
+ sa.column("is_deleted", sa.Integer),
+ sa.column("deleted_at", sa.DateTime),
+ )
+ op.execute(
+ permissions_table.update()
+ .where(permissions_table.c.permission_id == permission_id)
+ .where(permissions_table.c.is_deleted == 0)
+ .values(is_deleted=1, deleted_at=sa.text("CURRENT_TIMESTAMP(3)"))
+ )