feat: add user api

This commit is contained in:
tao.chen
2026-08-06 19:26:57 +08:00
parent 2894b1f06f
commit 2272e1f390
3 changed files with 230 additions and 0 deletions
+160
View File
@@ -29,6 +29,8 @@ 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)
Invariants
----------
@@ -122,6 +124,15 @@ class PlatformEmployeeCreate(BaseModel):
password: str = Field(min_length=8, max_length=72)
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
# ---------------------------------------------------------------------------
# System-admin context dependency
# ---------------------------------------------------------------------------
@@ -279,6 +290,30 @@ async def _count_active_admins(
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,
@@ -346,6 +381,131 @@ async def create_platform_employee(
return _envelope(context.request_id, platform_employee_payload(user, None))
@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."""
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 系统管理员",
)
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
await session.flush()
await session.refresh(user)
response_role: Roles | None = None
if user.platform_role_id is not None:
response_role = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
return _envelope(
context.request_id, platform_employee_payload(user, response_role),
)
@router.delete("/employees/{user_id}")
async def delete_platform_employee(
user_id: str,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Soft delete a platform user and cascade-soft-delete workspace memberships."""
user = await session.get(Users, user_id)
if user is None or user.is_deleted != 0:
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
if user_id == context.user.user_id:
raise HTTPException(status.HTTP_409_CONFLICT, "不能删除当前登录账号")
if user.status == "active" and user.platform_role_id is not None:
current_admin_role = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if current_admin_role is not None and current_admin_role.role_code == "admin":
remaining = await _count_active_system_admins(
session, exclude_user_id=user_id,
)
if remaining == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"platform 必须保留至少一个 active 系统管理员",
)
now = datetime.datetime.utcnow()
user.status = "disabled"
user.is_deleted = 1
user.deleted_at = now
await session.execute(
update(WorkspaceMembers)
.where(
WorkspaceMembers.user_id == user_id,
WorkspaceMembers.is_deleted == 0,
)
.values(is_deleted=1, deleted_at=now)
)
await session.flush()
return _envelope(
context.request_id,
{"user_id": user_id, "deleted": True},
)
# ---------------------------------------------------------------------------
# Workspace CRUD
# ---------------------------------------------------------------------------