This commit is contained in:
Winnie
2026-08-05 17:46:05 +08:00
101 changed files with 8797 additions and 3323 deletions
+209 -47
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`。前端据此决定是否渲染"系统管理"入口。详见 §七。
---
## 二、统一约定
@@ -89,7 +92,7 @@
### 3.2 `POST /api/v1/workspace-directories`
创建一个**逻辑目录**(RustFS 上是隐式前缀,无需落对象)。
创建一个**逻辑目录**(对象存储上是隐式前缀,无需落对象)。
- **请求体**:
```json
@@ -160,7 +163,7 @@
### 3.7 `POST /api/v1/scripts/upload?file_name=...&parent_path=...&visibility=...`
multipart/binary 形式上传大文件(走 presigned PUT)。
multipart/binary 形式上传大文件(走 server-proxied PUT,详见 §九)。
- **查询参数**: `file_name`(必填)、`parent_path`、`visibility`
- **请求体**: 原始文件字节(`Content-Type` 必须与脚本类型匹配)
@@ -200,7 +203,7 @@ multipart/binary 形式上传大文件(走 presigned PUT)。
### 3.11 `POST /api/v1/scripts/{script_id}/versions`
发布一个**稳定版本**(immutable,绑定到 `RUSTFS_VERSION_BUCKET`)。门禁同 §3.8。
发布一个**稳定版本**(immutable,绑定到 `S3_VERSION_BUCKET`)。门禁同 §3.8。
- **请求体**:
```json
@@ -240,11 +243,11 @@ multipart/binary 形式上传大文件(走 presigned PUT)。
### 3.14 `DELETE /api/v1/versions/{versions_id}`
从调度候选中**隐藏**此版本(不删除 RustFS 对象)。门禁:**owner 校验基于所属 `Scripts` 的 owner**——即"按整本 script 判定",而非"按版本发布者判定"。
从调度候选中**隐藏**此版本(不删除对象存储里的对象)。门禁:**owner 校验基于所属 `Scripts` 的 owner**——即"按整本 script 判定",而非"按版本发布者判定"。
### 3.15 `POST /api/v1/versions/{versions_id}/download-url`
生成 RustFS 的 presigned download URL。
生成对象存储的 presigned download URL(走 S3 兼容协议,local 模式下该 endpoint 在 s3 模式才生效)
- **请求体**:
```json
@@ -396,8 +399,8 @@ queued ──→ running ──┬─→ succeeded
| 方法 | 路径 | 说明 |
|---|---|---|
| `POST` | `/api/v1/data-resources/uploads` | 创建上传会话,返回 presigned PUT URL + `upload_id` |
| `POST` | `/api/v1/data-resources/uploads/{upload_id}/complete` | 完成上传(写 `StorageObjects` 行) |
| `POST` | `/api/v1/data-resources/uploads` | 创建上传会话,返回 `upload_id` + `upload_path` |
| `PUT` | `/api/v1/data-resources/uploads/{upload_id}` | 上传字节(请求体即文件内容) |
| `GET` | `/api/v1/data-resources` | 列表(workspace 范围) |
| `GET` | `/api/v1/data-resources/{id}` | 详情 |
| `POST` | `/api/v1/data-resources/{id}/download-url` | 生成 presigned GET URL |
@@ -417,15 +420,18 @@ queued ──→ running ──┬─→ succeeded
**完整上传流程(前端应实现的模式)**:
```
1. POST /uploads → {upload_id, presigned_url}
2. PUT presigned_url with file bytes
3. POST /uploads/{upload_id}/complete
4. 服务器完成 → 200 {data: StorageObjectPayload}
1. POST /uploads → {upload_id, upload_path, expires_at}
2. PUT upload_path with raw file bytes (Content-Type: application/octet-stream)
3. 服务器端走 backend.put() → 200 {data: StorageObjectPayload}
```
字节经过 backend 进程(server-proxied upload),最大 100 MiB,由 backend
直接调 `AsyncStorageBackend.put()` 写入存储(不再走 presigned PUT 直传)。
前端无需关心 S3 协议或签名。
**小对象(<100 KiB)捷径**:直接调 `create_server_object` 把字节 base64 放进
`content_base64` 字段(走 `StorageClient.create_server_object`),前端无需
走 presigned PUT 三步。
`content_base64` 字段(JSON 体里走),内部走同一条 `AsyncStorageBackend.put`
路径,前端无需分两步。
---
@@ -484,11 +490,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 +632,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 +659,7 @@ Nginx: auth_request_set 捕获这两个变量,proxy_pass 到子进程并注入
浏览器收到响应,**自始至终未接触 Jupyter Token**
```
### 7.3 鉴权失败码
### 8.3 鉴权失败码
| 状态 | 触发条件 |
|---|---|
@@ -538,14 +672,18 @@ Nginx 把这些状态原样透传给浏览器,前端可在 `onerror` 里判断
---
## 、对象存储控制面
## 、对象存储控制面
> 路径前缀 `/internal/v1/...`,**前端不要直接调用**。这是 backend 内部
> 异步消息处理(Schedule worker)用的 RPC 端点,经 `StorageClient` HTTP
> 客户端访问。Backend 通过 ASGI `auth_request_set` 路由转发,外部无法
> 访问。
> 异步消息处理(Schedule worker)用的 RPC 端点,经 in-process ASGI 直接
> 转发(`storage_app` 路由被 `app.include_router` 进同一个 backend 进程),
> 外部无法访问。
### 8.1 `POST /internal/v1/uploads`
底层抽象:`common.storage.AsyncStorageBackend`(`put/get/delete/exists/stat/
list/get_url/copy`)。按 `settings.storage_backend` 选实现:`"s3"` 走
S3-兼容服务,`"local"` 走 `LOCAL_STORAGE_BASE_DIR` 子目录。
### 9.1 `POST /internal/v1/uploads`
创建上传会话。`Idempotency-Key` 必填,同 key + 同元数据 → 复用;同 key + 不同元数据 → 409。
@@ -558,23 +696,36 @@ Nginx 把这些状态原样透传给浏览器,前端可在 `onerror` 里判断
"content_type": "text/x-python",
"expected_size_bytes": 1024,
"expected_hash": "<optional sha256 hex>",
"idempotency_key": "..."
"idempotency_key": "...",
"visibility": "private",
"is_immutable": false
}
```
返回 `{upload_id, bucket_name, object_key, presigned_url, expires_in_seconds}`。
返回 `{upload_id, status, upload_path, expires_at}`。`upload_path` 是
第 9.2 步要 PUT 的端点(本进程内 `/internal/v1/uploads/{upload_id}`)。
### 8.2 `POST /internal/v1/uploads/{upload_id}/complete`
### 9.2 `PUT /internal/v1/uploads/{upload_id}`
完成上传。从 RustFS 读 HEAD → 校验 hash → 写 `StorageObjects` 行。
完成上传(server-proxied PUT)。**请求体即原始字节**,`Content-Type:
application/octet-stream`。后端 `await request.body()` 读字节 → 校验
size + sha256 → 调 `await backend.put(key, bytes, content_type=...,
metadata={"sha256": ...})` → 写 `StorageObjects` 行 → 标 session 为
completed。最大 100 MiB。
### 8.3 `POST /internal/v1/uploads/{upload_id}/abort`
> 历史:旧版本这一步是 `POST /uploads/{id}/complete`,靠
> presigned-PUT + head() 验证。已被 server-proxied PUT 取代。
主动放弃。释放 `UploadSessions` 行,对象不入库。
### 9.3 `POST /internal/v1/uploads/{upload_id}/abort`
### 8.4 `POST /internal/v1/objects`
主动放弃。删除可能已经写了一半的对象字节,释放 `UploadSessions` 行。
**单步创建**(不走 presigned PUT,字节随请求体直传)。适用 < 100 KiB 对象。
### 9.4 `POST /internal/v1/objects`
**单步创建**(不走两步上传,字节 base64 进 JSON 体)。适用 < 100 KiB
对象(避免 multipart/大请求体的前端复杂度)。内部直接调
`AsyncStorageBackend.put(key, content, content_type=...,
metadata={"sha256": ...})`。
```json
{
@@ -586,31 +737,39 @@ Nginx 把这些状态原样透传给浏览器,前端可在 `onerror` 里判断
"content_base64": "PHN0ZXAtY29udGVudD4=",
"visibility": "private",
"is_immutable": false,
"idempotency_key": "..."
"idempotency_key": "...",
"relative_path": null
}
```
### 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
生成 presigned GET URL(s3 模式:`AsyncStorageBackend.get_url()`;
local 模式:目前抛 `NotImplementedError`,需要 native FS serving 配合
nginx 静态 location)。
### 8.6 `DELETE /internal/v1/objects/{storage_object_id}`
### 9.6 `DELETE /internal/v1/objects/{storage_object_id}`
软删。`is_immutable == 1` 的对象拒绝删除。
软删。`is_immutable == 1` 的对象拒绝删除。`move_to_trash` 走跨后端
`copy + delete`(同一进程内的两个 backend 实例)。
### 8.7 usage_type → 桶路由(自动)
### 9.7 usage_type → 桶路由(自动)
| usage_type | 实际桶(env var) | 默认桶名 |
|---|---|---|
| `working_copy`, `public_script`, `data_resource`, `snapshot` | `RUSTFS_WORKSPACE_BUCKET` | `workspaces` |
| `version_artifact` | `RUSTFS_VERSION_BUCKET` | `versions` |
| `run_log`, `run_result` | `RUSTFS_RUN_LOG_BUCKET` | `run-logs` |
| `working_copy`, `public_script`, `data_resource`, `snapshot` | `S3_WORKSPACE_BUCKET` | `workspaces` |
| `version_artifact` | `S3_VERSION_BUCKET` | `versions` |
| `run_log`, `run_result` | `S3_RUN_LOG_BUCKET` | `run-logs` |
| (soft-delete target) | `S3_TRASH_BUCKET` | `trash` |
若 `Workspaces.artifact_bucket` 非空,优先用 per-workspace 桶(覆盖 usage_type 路由)。
桶在 `STORAGE_BACKEND=s3` 时是 4 个独立 S3 bucket,在
`STORAGE_BACKEND=local` 时是 `LOCAL_STORAGE_BASE_DIR` 下的 4 个子目录。
---
## 、健康检查
## 、健康检查
| 方法 | 路径 | 用途 |
|---|---|---|
@@ -620,7 +779,8 @@ Nginx 把这些状态原样透传给浏览器,前端可在 `onerror` 里判断
| `GET` | `/api/v1/health` | 公开健康检查(前端可访问) |
`/health/ready` 支持 `READINESS_TARGETS` 环境变量,逗号分隔的 `host:port`
列表,例如 `mysql:3306,rustfs:9000`,全部 TCP 通则返回 200,否则 503。
列表,例如 `mysql:3306,s3:9000`,全部 TCP 通则返回 200,否则 503。
`STORAGE_BACKEND=local` 模式下不需要 S3 host,列表里删掉即可。
---
@@ -630,18 +790,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` |