update:添加分页、成员添加优化
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user