feat: workspace CRUD at platform scope + drop audit_logs

新增系统管理模块 /api/v1/platform/*:
- workspace 实体 CRUD(创建/列表/详情/更新/软删除)
- workspace 成员 CRUD(添加/列表/更新/移除)
- SystemAdminContext 依赖,仅 platform_role_id 指向 admin 角色的用户可访问
- /api/v1/auth/me 与 /auth/login 增 is_system_admin 派生字段
- 不变量:每个 workspace 至少保留一个 admin;系统管理员无法自我移除成员
- 软删除 workspace 级联软删除其成员

清理 audit_logs(无运行时写入,纯死特性):
- baseline 移除 audit_logs 建表与三索引(20 → 19 tables)
- 删除 AuditLogs 模型定义与 __init__.py 导出
- 清理 migrate_system_json / migrate_legacy_workspaces 中的 audit 写入与回填代码

API.md 增 §七系统管理,§七/§八/§九 顺延为 §八/§九/§十,附录 A/B 同步更新。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-08-04 17:20:42 +08:00
co-authored by Claude Fable 5
parent c263ae6a5f
commit 45f0ff534f
9 changed files with 806 additions and 243 deletions
+152 -19
View File
@@ -25,9 +25,10 @@
4. [调度 (`/api/v1/schedules/...` + `/api/v1/schedule-runs/...`)](#四调度)
5. [数据资源 (`/api/v1/data-resources/...`)](#五数据资源)
6. [管理后台 (`/api/v1/admin/...`)](#六管理后台)
7. [Jupyter 路由 (Nginx `auth_request`)](#七jupyter-路由)
8. [对象存储控制面 (`/internal/v1/...`,同进程 RPC)](#八对象存储控制面)
9. [健康检查](#九健康检查)
7. [系统管理 (`/api/v1/platform/...`)](#七系统管理-apiv1platform)
8. [Jupyter 路由 (Nginx `auth_request`)](#八jupyter-路由)
9. [对象存储控制面 (`/internal/v1/...`,同进程 RPC)](#九对象存储控制面)
10. [健康检查](#十健康检查)
---
@@ -44,6 +45,8 @@
- 缺失或过期 → HTTP `401`
- 有效但用户不在 workspace → HTTP `403`(由 Nginx `auth_request` 透传给客户端)。
> `/api/v1/auth/me` 与 `/api/v1/auth/login` 响应中的 `data.user` 对象额外携带 `is_system_admin: bool` 字段,派生自 `users.platform_role_id` 指向的角色 `role_code == 'admin'` 且用户状态为 `active`。前端据此决定是否渲染"系统管理"入口。详见 §七。
---
## 二、统一约定
@@ -484,11 +487,139 @@ Base 前缀 `/api/v1/admin`。
> `PATCH` / `DELETE` 员工接口**不**涉及密码字段,也不返回密码相关信息。
## 七、Jupyter 路由
## 七、系统管理 (`/api/v1/platform/...`)
平台级(跨 workspace)管理接口,用于管理 workspace 实体与 workspace 成员。
所有端点要求调用者是**系统管理员**——其 `users.platform_role_id` 指向
`role_code='admin'` 的角色行,且 `users.status == 'active'`。系统管理员判定
通过 `GET /api/v1/auth/me` 响应中的 `data.user.is_system_admin` 字段(详见 §一)。
| 方法 | 路径 | 说明 |
|---|---|---|
| `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 的,用于恢复) |
| `PATCH` | `/api/v1/platform/workspaces/{workspace_id}` | 改 workspace 字段;`status` 仅允许 `active`/`archived` |
| `DELETE` | `/api/v1/platform/workspaces/{workspace_id}` | 软删 workspace;级联软删其成员 |
| `GET` | `/api/v1/platform/workspaces/{workspace_id}/members` | 列成员 |
| `POST` | `/api/v1/platform/workspaces/{workspace_id}/members` | 添加成员(返回 201) |
| `PATCH` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 改成员角色/状态 |
| `DELETE` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 软删成员 |
> **不变量**:
> - 每个 workspace 必须始终保留至少一个 `admin` 角色的活跃成员;对最后 admin 做降级 / 停用 / 删除 → 409。
> - 系统管理员不能通过 `DELETE .../members/{self}` 把自己移除(403)。唯一退出方式是 `DELETE /workspaces/{id}` 软删整个 workspace,后者会级联软删所有成员。
> - 列表类接口静默 `pageSize=100` 上限,无客户端分页参数(YAGNI)。
> - 跨 workspace 操作**不**需要 `?workspace_id=` query 参数,与 `/api/v1/admin/...`(workspace 内成员管理)不要混淆。
### 7.1 `POST /api/v1/platform/workspaces`
创建 workspace;创建者(当前系统管理员)自动成为该 workspace 的 `admin` 成员。
- **请求体字段**:
| 字段 | 类型 | 必填 | 限制 | 说明 |
|---|---|---|---|---|
| `workspace_code` | string | 是 | regex `^[a-z0-9-]{3,32}$`(类似 git repo 名) | 创建后冻结,不可改 |
| `workspace_name` | string | 是 | 1~150 字符 | 显示名称 |
| `quota_bytes` | int | 否 | ≥0,默认 `0` | 配额字节数,`0` 表示无配额 |
| `description` | string | 否 | ≤1000 字符 | |
- **服务端自动生成字段**(不接收):
- `workspace_id`(ULID)
- `active_root_uri`(`s3://workspaces/{workspace_id}/`)
- `status`(`"active"`)
- `created_by`(当前管理员 `user_id`)
- `created_at` / `updated_at`(DB 自动)
- **响应 201**:见下 §7.2 `WorkspacePayload`。
### 7.2 `GET /api/v1/platform/workspaces/{workspace_id}` / `WorkspacePayload`
- **响应 200**:
```json
{
"request_id": "...",
"data": {
"workspace_id": "01HXY...",
"workspace_code": "model-development",
"workspace_name": "模型开发 Workspace",
"active_root_uri": "s3://workspaces/01HXY.../",
"quota_bytes": 0,
"status": "active",
"description": null,
"created_by": "01HXY...",
"created_at": "2026-08-04T12:00:00.000",
"updated_at": null
},
"meta": {"count": ..., "page_size": 100}
}
```
### 7.3 `PATCH /api/v1/platform/workspaces/{workspace_id}`
部分更新。**不可改**:`workspace_id`、`workspace_code`、`active_root_uri`、`created_by`、时间戳、软删标记。
- **请求体字段**(全部可选):
| 字段 | 类型 | 限制 | 说明 |
|---|---|---|---|
| `workspace_name` | string | 1~150 | |
| `quota_bytes` | int | ≥0 | |
| `description` | string | ≤1000 | |
| `status` | string | `active` \| `archived` | **不允许 `disabled`**——软删须走 DELETE |
- 错误:`status="disabled"` → 422;已 disabled 的 workspace → 409。
### 7.4 `DELETE /api/v1/platform/workspaces/{workspace_id}`
软删除。允许从 `active` 或 `archived` 状态调用。
- **副作用**:
- 该 workspace 行:`status='disabled'`、`is_deleted=1`、`deleted_at=NOW()`
- **级联**:所有未删除的 `workspace_members` 行同步 `is_deleted=1`、`deleted_at=NOW()`
- 已 disabled 的 workspace 再删 → 409。
### 7.5 `POST /api/v1/platform/workspaces/{workspace_id}/members`
添加成员。
- **请求体字段**:
| 字段 | 类型 | 必填 | 限制 | 说明 |
|---|---|---|---|---|
| `user_id` | string | 是 | 26 字符 ULID | |
| `role_code` | string | 是 | `admin` \| `developer` | **不可填 `system_admin`**(那是用户级身份,不是 workspace 角色) |
- 服务端默认 `member_status='active'`。
- 用户不存在 → 404;用户已是该 workspace 成员 → 409。
### 7.6 `PATCH /api/v1/platform/workspaces/{workspace_id}/members/{user_id}`
修改成员的角色或状态。
- **请求体字段**(全部可选):
| 字段 | 类型 | 限制 | 说明 |
|---|---|---|---|
| `role_code` | string | `admin` \| `developer` | 降级最后 admin → 409 |
| `member_status` | string | `active` \| `disabled` \| `locked` | 停用 / 锁定最后 admin → 409 |
### 7.7 `DELETE /api/v1/platform/workspaces/{workspace_id}/members/{user_id}`
软删除成员。
- **自我移除保护**:`user_id == 当前管理员 user_id` → 403 "系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace"
- **末位 admin 保护**:若删除的是最后一个 `admin` 角色活跃成员 → 409
- 不存在的成员 → 404
---
## 八、Jupyter 路由
> **本节是 Nginx 行为,不是直接 HTTP 端点**。前端**不要**直接调用。
### 7.1 浏览器 → 用户打开 notebook
### 8.1 浏览器 → 用户打开 notebook
用户在前端点击某个 notebook,前端拼出 URL:
```
@@ -498,7 +629,7 @@ GET /jupyter/{workspace_id}/api/contents/{相对路径}.ipynb
WS /jupyter/{workspace_id}/api/kernels/...
```
### 7.2 Nginx `auth_request` 鉴权
### 8.2 Nginx `auth_request` 鉴权
Nginx 收到上述请求后,**先**发一个内部子请求:
```
@@ -525,7 +656,7 @@ Nginx: auth_request_set 捕获这两个变量,proxy_pass 到子进程并注入
浏览器收到响应,**自始至终未接触 Jupyter Token**
```
### 7.3 鉴权失败码
### 8.3 鉴权失败码
| 状态 | 触发条件 |
|---|---|
@@ -538,14 +669,14 @@ Nginx 把这些状态原样透传给浏览器,前端可在 `onerror` 里判断
---
## 、对象存储控制面
## 、对象存储控制面
> 路径前缀 `/internal/v1/...`,**前端不要直接调用**。这是 backend 内部
> 异步消息处理(Schedule worker)用的 RPC 端点,经 `StorageClient` HTTP
> 客户端访问。Backend 通过 ASGI `auth_request_set` 路由转发,外部无法
> 访问。
### 8.1 `POST /internal/v1/uploads`
### 9.1 `POST /internal/v1/uploads`
创建上传会话。`Idempotency-Key` 必填,同 key + 同元数据 → 复用;同 key + 不同元数据 → 409。
@@ -564,15 +695,15 @@ Nginx 把这些状态原样透传给浏览器,前端可在 `onerror` 里判断
返回 `{upload_id, bucket_name, object_key, presigned_url, expires_in_seconds}`。
### 8.2 `POST /internal/v1/uploads/{upload_id}/complete`
### 9.2 `POST /internal/v1/uploads/{upload_id}/complete`
完成上传。从 RustFS 读 HEAD → 校验 hash → 写 `StorageObjects` 行。
### 8.3 `POST /internal/v1/uploads/{upload_id}/abort`
### 9.3 `POST /internal/v1/uploads/{upload_id}/abort`
主动放弃。释放 `UploadSessions` 行,对象不入库。
### 8.4 `POST /internal/v1/objects`
### 9.4 `POST /internal/v1/objects`
**单步创建**(不走 presigned PUT,字节随请求体直传)。适用 < 100 KiB 对象。
@@ -590,15 +721,15 @@ Nginx 把这些状态原样透传给浏览器,前端可在 `onerror` 里判断
}
```
### 8.5 `POST /internal/v1/objects/{storage_object_id}/download-url`
### 9.5 `POST /internal/v1/objects/{storage_object_id}/download-url`
生成 presigned GET URL。
### 8.6 `DELETE /internal/v1/objects/{storage_object_id}`
### 9.6 `DELETE /internal/v1/objects/{storage_object_id}`
软删。`is_immutable == 1` 的对象拒绝删除。
### 8.7 usage_type → 桶路由(自动)
### 9.7 usage_type → 桶路由(自动)
| usage_type | 实际桶(env var) | 默认桶名 |
|---|---|---|
@@ -610,7 +741,7 @@ Nginx 把这些状态原样透传给浏览器,前端可在 `onerror` 里判断
---
## 、健康检查
## 、健康检查
| 方法 | 路径 | 用途 |
|---|---|---|
@@ -630,18 +761,20 @@ Nginx 把这些状态原样透传给浏览器,前端可在 `onerror` 里判断
|---|---|---|
| 400 | 参数错误 | Pydantic 校验失败 |
| 401 | 未鉴权 | JWT 缺失/无效 |
| 403 | 鉴权失败 | 非 workspace 成员 / `is_locked` 阻写 |
| 403 | 鉴权失败 | 非 workspace 成员 / `is_locked` 阻写 / **非系统管理员访问 `/api/v1/platform/*`** / 系统管理员自我移除 workspace 成员 |
| 404 | 不存在 | resource_id / script_id / schedule_id 找不到 |
| 409 | 冲突 | DAG 无效 / 同 idempotency_key 不同元数据 / 目标已存在 / `is_immutable` 阻删 |
| 409 | 冲突 | DAG 无效 / 同 idempotency_key 不同元数据 / 目标已存在 / `is_immutable` 阻删 / **workspace 末位 admin 保护** |
| 412 | 条件失败 | `source_object_id` 与当前工作副本不一致 |
| 413 | 太大 | 内容超过 100 MiB / 10 MiB |
| 422 | 语义错误 | 文件名非法 / cron 表达式非法 / 路径逃逸 |
| 422 | 语义错误 | 文件名非法 / cron 表达式非法 / 路径逃逸 / **`workspace_code` 不匹配 `^[a-z0-9-]{3,32}$` / `status="disabled"` 走 PATCH** |
| 500 | 内部错误 | DB / 存储不可达 |
## 附录 B — 状态枚举
| 类型 | 取值 |
|---|---|
| `Workspaces.status` | `active` / `archived` / `disabled`(`disabled` 由 DELETE 设置,PATCH 不允许设) |
| `WorkspaceMembers.member_status` | `active` / `disabled` / `locked` |
| `StorageObjects.usage_type` | `data_resource` / `version_artifact` / `snapshot` / `run_log` / `run_result` / `working_copy` / `public_script` |
| `StorageObjects.object_status` | `available` / `deleted` |
| `StorageObjects.visibility` | `private` / `workspace` / `public` |