update:添加分页、成员添加优化

This commit is contained in:
xiaozhu
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 5a90f7db36
commit b7b8e28b52
20 changed files with 1729 additions and 920 deletions
@@ -73,6 +73,7 @@ from backend.api.platform._deps import (
system_admin_context,
)
from backend.api.platform.employees import router as employees_router
from backend.api.platform.members import router as members_router
from backend.api.platform.roles import router as roles_router
from backend.api.platform.workspaces import router as workspaces_router
@@ -81,6 +82,7 @@ from backend.api.platform.workspaces import router as workspaces_router
router = APIRouter()
router.include_router(employees_router)
router.include_router(workspaces_router)
router.include_router(members_router)
router.include_router(roles_router)
# 保留 system_admin_context 的 re-export,供其他文件使用
@@ -0,0 +1,60 @@
"""Cursor (keyset) pagination helpers for platform list endpoints.
Cursor encodes the sort key ``(created_at, id)`` as a URL-safe base64
string. Clients pass it back via ``?cursor=`` to fetch the next page.
Invalid cursors raise HTTP 400 — never silently treated as page 1.
"""
from __future__ import annotations
import base64
import datetime
from typing import Any
from fastapi import HTTPException, status
DEFAULT_PAGE_LIMIT = 10
MAX_PAGE_LIMIT = 200
def encode_cursor(created_at: datetime.datetime, row_id: str) -> str:
"""Encode ``(created_at, id)`` into an opaque cursor string."""
if created_at.tzinfo is not None:
created_at = created_at.replace(tzinfo=None)
raw = f"{created_at.isoformat()}|{row_id}".encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def decode_cursor(cursor: str) -> tuple[datetime.datetime, str]:
"""Decode a cursor; raise 400 on malformed input."""
try:
padded = cursor + "=" * (-len(cursor) % 4)
raw = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
ts_part, _, row_id = raw.partition("|")
if not ts_part or not row_id:
raise ValueError("missing parts")
created_at = datetime.datetime.fromisoformat(ts_part)
if created_at.tzinfo is not None:
created_at = created_at.replace(tzinfo=None)
return created_at, row_id
except (ValueError, TypeError, UnicodeDecodeError) as exc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"无效的分页 cursor",
) from exc
def page_meta(
*,
limit: int,
page_count: int,
total_count: int,
next_cursor: str | None,
) -> dict[str, Any]:
return {
"limit": limit,
"page_count": page_count,
"total_count": total_count,
"has_more": next_cursor is not None,
"next_cursor": next_cursor,
}
+54 -6
View File
@@ -11,9 +11,9 @@ 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 fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import or_, select, update
from sqlalchemy import func, or_, select, tuple_, update
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import database_session
@@ -25,6 +25,13 @@ from backend.api.platform._deps import (
_load_role_by_code,
system_admin_context,
)
from backend.api.platform._pagination import (
DEFAULT_PAGE_LIMIT,
MAX_PAGE_LIMIT,
decode_cursor,
encode_cursor,
page_meta,
)
# 平台角色 code 的字符串约束:与 RoleCreate.role_code 一致。
# 之所以从 Literal["admin","developer"] 放宽为 str,是因为 ``listPlatformRoles``
@@ -91,22 +98,63 @@ def platform_employee_payload(
# 列出整个平台的非删除用户;不局限于某一个工作区。
@router.get("/employees")
async def list_platform_employees(
limit: int = Query(default=DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT),
cursor: str | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List every non-soft-deleted platform user."""
"""List non-soft-deleted platform users with cursor pagination + search."""
base_filters = [Users.is_deleted == 0]
keyword = (q or "").strip()
if keyword:
like = f"%{keyword}%"
base_filters.append(
or_(
Users.display_name.like(like),
Users.username.like(like),
Users.email.like(like),
)
)
total_count = int(
await session.scalar(
select(func.count()).select_from(Users).where(*base_filters)
)
or 0
)
page_filters = list(base_filters)
if cursor is not None:
cursor_ts, cursor_id = decode_cursor(cursor)
page_filters.append(
tuple_(Users.created_at, Users.user_id) > (cursor_ts, cursor_id)
)
rows = (
await session.execute(
select(Users, Roles)
.outerjoin(Roles, Roles.role_id == Users.platform_role_id)
.where(Users.is_deleted == 0)
.where(*page_filters)
.order_by(Users.created_at, Users.user_id)
.limit(limit + 1)
)
).all()
has_more = len(rows) > limit
page_rows = rows[:limit]
next_cursor = None
if has_more and page_rows:
last_user = page_rows[-1][0]
next_cursor = encode_cursor(last_user.created_at, last_user.user_id)
return _envelope(
context.request_id,
[platform_employee_payload(user, role) for user, role in rows],
{"count": len(rows)},
[platform_employee_payload(user, role) for user, role in page_rows],
page_meta(
limit=limit,
page_count=len(page_rows),
total_count=total_count,
next_cursor=next_cursor,
),
)
+298
View File
@@ -0,0 +1,298 @@
"""Workspace membership CRUD endpoints.
``GET .../members`` admits system admins or active workspace members;
write endpoints require ``system_admin_context``.
"""
from __future__ import annotations
import datetime
from typing import Any, Literal
from common.db.models import Roles, Users, WorkspaceMembers
from common.ids import new_ulid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
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,
system_admin_context,
)
from backend.api.platform.workspaces import (
_load_workspace,
member_payload,
)
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
LIST_PAGE_SIZE = 100
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
@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},
)
+62 -285
View File
@@ -1,7 +1,7 @@
"""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.
"""Workspace CRUD endpoints.
Membership endpoints live in ``members.py``. Soft-delete cascades for
workspace DELETE still live here.
"""
from __future__ import annotations
@@ -12,29 +12,29 @@ 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 fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select, update
from sqlalchemy import func, or_, select, tuple_, update
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import current_user, database_session
from backend.api.dependencies import database_session
from backend.api.platform._deps import (
SystemAdminContext,
_count_active_admins,
_envelope,
_is_system_admin,
_load_role_by_code,
system_admin_context,
)
from backend.api.platform._pagination import (
DEFAULT_PAGE_LIMIT,
MAX_PAGE_LIMIT,
decode_cursor,
encode_cursor,
page_meta,
)
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):
@@ -55,24 +55,6 @@ class WorkspaceUpdate(BaseModel):
# '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,
@@ -114,28 +96,66 @@ async def _load_workspace(session: AsyncSession, workspace_id: str) -> Workspace
@router.get("/workspaces")
async def list_workspaces(
limit: int = Query(default=DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT),
cursor: str | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List active/archived workspaces. Soft-deleted rows are filtered out.
"""List active/archived workspaces with cursor pagination + search."""
base_filters = [
Workspaces.status != "disabled",
Workspaces.is_deleted == 0,
]
keyword = (q or "").strip()
if keyword:
like = f"%{keyword}%"
base_filters.append(
or_(
Workspaces.workspace_name.like(like),
Workspaces.workspace_code.like(like),
Workspaces.description.like(like),
)
)
total_count = int(
await session.scalar(
select(func.count()).select_from(Workspaces).where(*base_filters)
)
or 0
)
page_filters = list(base_filters)
if cursor is not None:
cursor_ts, cursor_id = decode_cursor(cursor)
page_filters.append(
tuple_(Workspaces.created_at, Workspaces.workspace_id)
> (cursor_ts, cursor_id)
)
Silent ``pageSize=100`` cap — YAGNI on real pagination until needed.
"""
rows = (
await session.execute(
select(Workspaces)
.where(
Workspaces.status != "disabled",
Workspaces.is_deleted == 0,
)
.where(*page_filters)
.order_by(Workspaces.created_at, Workspaces.workspace_id)
.limit(LIST_PAGE_SIZE)
.limit(limit + 1)
)
).scalars().all()
has_more = len(rows) > limit
page_rows = list(rows[:limit])
next_cursor = None
if has_more and page_rows:
last = page_rows[-1]
next_cursor = encode_cursor(last.created_at, last.workspace_id)
return _envelope(
context.request_id,
[workspace_payload(w) for w in rows],
{"count": len(rows), "page_size": LIST_PAGE_SIZE},
[workspace_payload(w) for w in page_rows],
page_meta(
limit=limit,
page_count=len(page_rows),
total_count=total_count,
next_cursor=next_cursor,
),
)
@router.post("/workspaces", status_code=status.HTTP_201_CREATED)
@@ -252,246 +272,3 @@ async def delete_workspace(
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},
)
+57
View File
@@ -0,0 +1,57 @@
"""Unit tests for platform cursor pagination helpers."""
from __future__ import annotations
import datetime
import pytest
from fastapi import HTTPException
from backend.api.platform._pagination import (
decode_cursor,
encode_cursor,
page_meta,
)
def test_encode_decode_roundtrip() -> None:
created_at = datetime.datetime(2026, 3, 15, 12, 30, 45, 123000)
row_id = "01HXY9C5B8N3K4P7Q6RT2V0J8D"
cursor = encode_cursor(created_at, row_id)
decoded_ts, decoded_id = decode_cursor(cursor)
assert decoded_ts == created_at
assert decoded_id == row_id
def test_decode_strips_timezone() -> None:
created_at = datetime.datetime(2026, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
cursor = encode_cursor(created_at, "abc")
decoded_ts, decoded_id = decode_cursor(cursor)
assert decoded_ts.tzinfo is None
assert decoded_id == "abc"
def test_decode_invalid_cursor_raises_400() -> None:
with pytest.raises(HTTPException) as exc_info:
decode_cursor("not-a-valid-cursor!!!")
assert exc_info.value.status_code == 400
def test_page_meta_has_more() -> None:
meta = page_meta(
limit=10,
page_count=10,
total_count=25,
next_cursor="abc",
)
assert meta["has_more"] is True
assert meta["next_cursor"] == "abc"
assert meta["total_count"] == 25
meta_end = page_meta(
limit=10,
page_count=5,
total_count=25,
next_cursor=None,
)
assert meta_end["has_more"] is False