feat: role CRUD
This commit is contained in:
@@ -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.
|
||||
- **`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 文件只做 `<Page key={...} />` 重挂载,二者不要混在一起。
|
||||
- **拆分前先列调用面**(`grep "from <old_path>"`),任何外部 import 路径必须仍然可用 — 用 re-export 或 shim 兜底,不要让调用方被迫改。
|
||||
- **拆分后**每个新文件 ≤ 500 行是硬约束,验证方式:`wc -l <file>` 或 CI 脚本。
|
||||
- 拆分本身是**纯结构调整**,endpoint 行为 / URL / 响应 schema 零变化 — 不要顺手"清理"。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
@@ -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 {},
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
@@ -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)},
|
||||
)
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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)"))
|
||||
)
|
||||
Reference in New Issue
Block a user