feat: add user api
This commit is contained in:
@@ -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 路由
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user