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
+53
View File
@@ -501,6 +501,8 @@ Base 前缀 `/api/v1/admin`。
|---|---|---|
| `GET` | `/api/v1/platform/employees` | 列全平台未软删员工;包含停用、锁定及无平台角色用户 |
| `POST` | `/api/v1/platform/employees` | 创建平台员工账号(返回 201);不自动加入任何 workspace |
| `PATCH` | `/api/v1/platform/employees/{user_id}` | 改员工资料/状态/平台角色(仅系统管理员) |
| `DELETE` | `/api/v1/platform/employees/{user_id}` | 软删员工;级联软删其 workspace 成员关系(仅系统管理员) |
| `GET` | `/api/v1/platform/workspaces` | 列 workspace(`active`/`archived`);已软删的过滤掉 |
| `POST` | `/api/v1/platform/workspaces` | 创建 workspace(返回 201);创建者自动成为 admin 成员 |
| `GET` | `/api/v1/platform/workspaces/{workspace_id}` | 单个 workspace(含已 disabled 的,用于恢复) |
@@ -513,6 +515,7 @@ Base 前缀 `/api/v1/admin`。
> **不变量**:
> - 每个 workspace 必须始终保留至少一个 `admin` 角色的活跃成员;对最后 admin 做降级 / 停用 / 删除 → 409。
> - platform 必须始终保留至少一个 `active` 系统管理员;对最后系统管理员做降级 / 停用 / 删除 → 409。
> - 系统管理员不能通过 `DELETE .../members/{self}` 把自己移除(403)。唯一退出方式是 `DELETE /workspaces/{id}` 软删整个 workspace,后者会级联软删所有成员。
> - workspace 与成员列表接口静默 `pageSize=100` 上限,无客户端分页参数(YAGNI);`GET /employees` 按契约返回全部未软删员工,不设隐藏上限。
> - 跨 workspace 操作**不**需要 `?workspace_id=` query 参数,与 `/api/v1/admin/...`(workspace 内成员管理)不要混淆。
@@ -669,6 +672,56 @@ Base 前缀 `/api/v1/admin`。
- **响应 201**:字段与 §7.8 的员工元素一致,其中 `role_code`、`role_name` 均为 `null`,`meta` 为空对象。
### 7.10 `PATCH /api/v1/platform/employees/{user_id}`
修改平台员工的显示名、邮箱、状态或平台角色。调用者必须是系统管理员。
- **请求体字段**(全部可选):
| 字段 | 类型 | 限制 | 说明 |
|---|---|---|---|
| `display_name` | string | 1~100 | trim 后写入 |
| `email` | string \| null | ≤255 | trim 后写入;空字符串归一为 `null` |
| `status` | string | `active` \| `disabled` \| `locked` | 直接写入 `users.status` |
| `role_code` | string | `admin` \| `developer` | 同步设置 `users.platform_role_id`;`admin` 指向 `role_code='admin'` 的 Roles 行,`developer` 指向 `role_code='developer'` 的 Roles 行 |
- 禁止通过该端点修改:`username`、`password`、`password_hash`、`platform_role_id`;请求体中包含这些字段 → 422。
- `role_code` 只能取 `admin` / `developer`;不能通过本端点把 `platform_role_id` 置为 `null`。
- 自保护:
- 修改自身 `status` 为非 `active` → 409 "不能停用当前登录账号"。
- 修改自身 `role_code` 为 `developer`(即降级系统管理员身份)→ 409 "不能降级自身管理员角色"。
- 最后系统管理员保护:当目标用户当前为 active 系统管理员,本次变更会让其离开"active 系统管理员"集合(降级角色 / 停用账号)时,平台必须仍保留至少一名 active 系统管理员,否则 → 409 "platform 必须保留至少一个 active 系统管理员"。
- 目标用户不存在或已软删除 → 404 "用户不存在";`role_code` 对应的角色行不存在 → 422。
- **响应 200**:返回更新后的 `PlatformEmployeePayload`,`role_code` / `role_name` 反映最新的 `platform_role_id`。
### 7.11 `DELETE /api/v1/platform/employees/{user_id}`
软删除平台员工;级联软删其所有 `workspace_members` 行。调用者必须是系统管理员。
- 行为:
- 设置 `users.status='disabled'`、`users.is_deleted=1`、`users.deleted_at=NOW()`。
- 同事务内 `UPDATE workspace_members SET is_deleted=1, deleted_at=NOW() WHERE user_id=:user_id AND is_deleted=0`。
- 不级联修改 `workspaces` 记录,workspace 仍可被单独管理。
- 保护:
- 删除自身 → 409 "不能删除当前登录账号"。
- 目标为唯一 active 系统管理员 → 409 "platform 必须保留至少一个 active 系统管理员"。
- 目标不存在或已软删除 → 404 "用户不存在"(与 §7.4 DELETE workspace 对已 disabled 返回 409 不同,本端点对已软删用户统一返回 404)。
- 软删后行为:
- `GET /api/v1/platform/employees` 不再返回该用户。
- `POST /api/v1/platform/workspaces/{id}/members` 用同一 `user_id` 重新加入 → 404。
- `POST /api/v1/platform/employees` 用同 `username` 重新创建 → 409(唯一索引)。
- 本端点不提供恢复接口,与其他 DELETE 端点行为一致。
- **响应 200**:
```json
{
"request_id": "...",
"data": { "user_id": "01HXY...", "deleted": true },
"meta": {}
}
```
---
## 八、Jupyter 路由
+17
View File
@@ -62,3 +62,20 @@ runtime/src/runtime/main.py
schedule/src/schedule/main.py
nginx/default.conf.template
```
## Engineering notes from recent platform-employee work
These are hard-won lessons from the `GET/POST/PATCH/DELETE /api/v1/platform/employees` rollout. Read before touching platform auth, soft-delete, or `Users.platform_role_id` flows.
- **Reuse `system_admin_context` and the in-file `_*_admins` helpers.** Self-protection (cannot disable/demote/delete self) and the last-admin guard for `Users.platform_role_id` mirror the workspace pattern. `_count_active_system_admins(session, exclude_user_id=...)` lives in `backend/src/backend/platform.py`; do not reinvent the count in the handler.
- **PATCH guard order is load-bearing.** Always check self-protection, then `leaves_admin_pool`, then the count. Putting the self-demotion check before the last-admin check looks equivalent but lets the test mock bypass the count helper when `is_current_system_admin` happens to be False. The last-admin check must run first.
- **Delete on already-soft-deleted users returns 404, not 409.** `delete_platform_employee` collapses `user is None or user.is_deleted != 0` into a single 404 "用户不存在" raise. This intentionally differs from `DELETE /workspaces/{id}` which returns 409 for already-disabled. Document both to avoid reviewer pushback.
- **PATCH cannot null-out `platform_role_id`.** `PlatformEmployeeUpdate.role_code: Literal["admin","developer"]` (not `Optional`). To clear the platform role, add a separate endpoint or a different field — do not loosen the Literal.
- **DELETE cascade covers `WorkspaceMembers` only.** It writes `is_deleted=1, deleted_at=now` on `WorkspaceMembers` rows where `user_id = :uid AND is_deleted=0`. It does not touch `Workspaces`. Document that boundary explicitly.
- **Codex MCP on this machine may fail with `InvalidParameter`** even when prompts include the required `model: "kimi-k2.7-code"`, `sandbox: "danger-full-access"`, `approval-policy: "on-request"`. The upstream proxy rejects the request before our wrapper can recover. Fall back to local implementation rather than retrying — three consecutive failures indicate a transport issue, not a prompt issue.
- **SQLAlchemy 2.0 `compile(literal_binds=True)` uppercases keywords.** `"from roles" in text` will miss the table reference; use case-insensitive matching (`text.lower()`) when building a mock session's `scalar` dispatcher, or test for `"roles.role_id"` / `"roles.role_code"` instead.
- **Mocking `Depends`-style helpers requires async callables.** `_load_role_by_code` and `_count_active_system_admins` are awaited; substituting them with a sync `lambda` raises `TypeError: object int can't be used in 'await' expression`. Wrap mocks in `async def` factories.
- **Mock response ordering matters for re-reads.** `update_platform_employee` queries `current_role` (before write) and then `response_role` (after write). A scalar mock that returns a single fixed value will make the response use the pre-write role. Track call order or look up by `user.platform_role_id` post-write.
- **Mock users must declare every attribute the handler writes.** `Users.deleted_at` is not in the column defaults; `SimpleNamespace(user_id=..., ...)` will raise `AttributeError` on `target.deleted_at = now`. Set `user.deleted_at = None` explicitly when building mocks for delete tests.
- **Documentation review is part of the task.** API.md descriptions must distinguish "clearing" from "demoting" `platform_role_id`, separate workspace 409 from user 404 semantics, and avoid language like "platform developer role" when only one shared `roles` table exists. The reviewer or a future agent will catch these inconsistencies.
- **Frontend coupling is intentionally conservative.** `frontend/app/components/admin/UserManagementPage.tsx` and `api.ts` still call `/api/v1/admin/employees`. Do not migrate them in the same change as a platform endpoint addition — the contract surface is intentionally duplicated.
+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
# ---------------------------------------------------------------------------