Files
model-platform/API.md
T
tao.chen 309b657d35 docs: align with new storage architecture (s3 + local + server-proxied PUT)
All operator- and developer-facing docs updated to reflect:

  - The unified AsyncStorageBackend abstraction (s3 + local backends).
  - The STORAGE_BACKEND toggle ("s3" default, "local" for dev /
    single-node / air-gapped deployments).
  - The 4-purpose-bucket layout (workspace / version / run_log / trash)
    in both modes — 4 separate S3 buckets in s3 mode, 4 subdirectories
    of LOCAL_STORAGE_BASE_DIR in local mode.
  - The S3_* env var naming (was RUSTFS_*).
  - The server-proxied upload flow (was browser-direct presign-PUT):
    POST /internal/v1/uploads → PUT /internal/v1/uploads/{id} with
    raw bytes → server calls backend.put().
  - The factory helpers workspaces_root() (runtime's view of the
    workspace bucket on disk) and rclone_remote_spec() (s3-mode mount
    source).
  - The "two settings describing the same thing" cleanup: the deleted
    settings.workspace_root, settings.workspaces_root, and
    settings.remote_bucket fields.

Files touched:
  - API.md (§5 data-resource upload flow, §9 storage control plane,
    §10 readiness example)
  - ARCHITECTURE.md (storage layer diagram)
  - CLAUDE.md (architecture description + volume-preservation note)
  - DEVELOP.md (settings list, Storage section, "Wire a new bucket"
    how-to, dev-export example, troubleshooting network hint)
  - README.md (architecture diagram, container table, quick-start
    credentials note, tear-down note, Storage layout section)
  - REFACTOR_NOTES.md (final container list with s3 explanation)
  - backend/README.md (storage backend description)
  - migrations/data/README.md (step 11/12 record mentioning object
    storage)

A handful of historical "RustFS" mentions are intentionally retained
where they name a specific S3-compatible product (e.g. as an example
in REFACTOR_NOTES.md's container list) or document the pre-2026
abstraction name (DEVELOP.md Storage section).
2026-08-05 13:13:20 +08:00

832 lines
29 KiB
Markdown

# 模型平台接口文档
> 本文档面向**前端开发者与第三方集成方**。所有接口的入口是 Nginx
> 网关(默认 `http://localhost:8888`),除 `/api/v1/auth/jupyter` 由
> Nginx `auth_request` 自动调用,其他接口都通过 `/api/v1/...` 同源访问。
>
> 服务端基础 URL 示例: `http://localhost:8888`
>
> 通用响应外壳:
> ```json
> {
> "request_id": "01HXY...",
> "data": { ... },
> "meta": {}
> }
> ```
> 错误响应为标准 HTTP 4xx / 5xx,body 为 `{"detail": "..."}` 或
> `{"code": "...", "message": "...", "details": {}}`。
## 目录
1. [鉴权](#一鉴权)
2. [统一约定](#二统一约定)
3. [脚本 / Notebook (`/api/v1/scripts/...`)](#三脚本--notebook)
4. [调度 (`/api/v1/schedules/...` + `/api/v1/schedule-runs/...`)](#四调度)
5. [数据资源 (`/api/v1/data-resources/...`)](#五数据资源)
6. [管理后台 (`/api/v1/admin/...`)](#六管理后台)
7. [系统管理 (`/api/v1/platform/...`)](#七系统管理-apiv1platform)
8. [Jupyter 路由 (Nginx `auth_request`)](#八jupyter-路由)
9. [对象存储控制面 (`/internal/v1/...`,同进程 RPC)](#九对象存储控制面)
10. [健康检查](#十健康检查)
---
## 一、鉴权
平台用 **JWT (HS256)**。登录后,前端在后续请求里**任选一种**携带方式:
- **Cookie**(推荐用于浏览器):登录成功后后端种 `Authorization` 或自定义 cookie;前端无需手写。
- **`Authorization: Bearer <token>`**(推荐用于脚本与第三方)。
`JWT_SECRET` 由后端从 `Settings.jwt_secret` 读取,前端不需要知道,只需要保证登录态带过来即可。
-`Authorization``Cookie` 同时存在时,后端**优先**使用 `Authorization`
- 缺失或过期 → 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`。前端据此决定是否渲染"系统管理"入口。详见 §七。
---
## 二、统一约定
| 类别 | 约定 |
|---|---|
| ID 格式 | 全部为 ULID(26 字符),如 `01HXY9C5B8N3K4P7Q6RT2V0J8D` |
| 时间戳 | ISO-8601 UTC,毫秒精度,如 `2026-07-31T11:23:45.123` |
| 时区 | 所有 `*_at` 字段均为 UTC,前端需自行转换显示 |
| 分页 | 大列表接口使用 `limit` (≤200) + 隐式 cursor,无 `offset` |
| 幂等键 | 上传类接口要求 `Idempotency-Key` 请求头,≥8 字符,≤128 字符 |
| 软删 | 删除操作走 `is_deleted` 软删,不返回 404;再次查询时已软删资源 `status="deleted"` |
| 排序 | 列表默认按业务键倒序(更新时间 / 入队时间等) |
| 鉴权头 | 见 §一 |
---
## 三、脚本 / Notebook
> 业务概念: `Scripts` 是用户工作区里的脚本或 notebook,`StorageObjects`
> 是它在对象存储里的"工作副本",`Versions` 是 immutable 的稳定版本。
> 写操作受 **is_locked 门禁 + owner 校验** 保护(架构 V3.1 §4)。
### 3.1 `GET /api/v1/workspace-tree`
列出当前用户在 workspace 内的**目录树**(从 `StorageObjects.relative_path` 派生)。
- **鉴权**: workspace 成员
- **请求体**: 无
- **响应**:
```json
{
"request_id": "...",
"data": {
"directories": [
{"path": "scripts", "name": "scripts", "parent_path": ""},
{"path": "scripts/etl", "name": "etl", "parent_path": "scripts"}
]
},
"meta": {"directory_count": 2}
}
```
### 3.2 `POST /api/v1/workspace-directories`
创建一个**逻辑目录**(对象存储上是隐式前缀,无需落对象)。
- **请求体**:
```json
{
"directory_name": "etl",
"parent_path": "scripts"
}
```
- **响应 201**:
```json
{
"request_id": "...",
"data": {"path": "scripts/etl", "name": "etl", "parent_path": "scripts"}
}
```
### 3.3 `DELETE /api/v1/workspace-directories?path=...`
删除一个目录(以及目录下当前用户拥有的所有 `Scripts`,**会触发 is_locked 校验**)。
- **查询参数**:
| 名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `path` | string | 是 | 相对路径,例如 `scripts/etl` |
- **响应**:
```json
{
"data": {
"path": "scripts/etl",
"status": "deleted",
"deleted_scripts": 3,
"versions_preserved": true
}
}
```
### 3.4 `GET /api/v1/scripts`
列出当前 workspace 内**全部 active 脚本**。不受 is_locked 影响(读路径不锁)。
- **响应**: `data` 为 `ScriptPayload` 数组(见 §3.10)。
### 3.5 `GET /api/v1/scripts/{script_id}`
取单个脚本详情。
### 3.6 `POST /api/v1/scripts`
创建一个脚本(直接走 `create_server_object` 上传)。
- **请求体**:
```json
{
"script_name": "train.py",
"script_type": "python",
"content": "print('hello')",
"visibility": "workspace",
"parent_path": "scripts"
}
```
| 字段 | 必填 | 说明 |
|---|---|---|
| `script_name` | 是 | 文件名;后端按 `script_type` 补齐扩展名(`.py` / `.ipynb`) |
| `script_type` | 是 | `python` \| `notebook` |
| `content` | 是 | 文本内容(`.ipynb` 必须是合法 JSON,含 `cells` 数组) |
| `visibility` | 否 | `private` \| `workspace` (默认) \| `public` |
| `parent_path` | 否 | 父目录路径 |
### 3.7 `POST /api/v1/scripts/upload?file_name=...&parent_path=...&visibility=...`
multipart/binary 形式上传大文件(走 server-proxied PUT,详见 §九)。
- **查询参数**: `file_name`(必填)、`parent_path`、`visibility`
- **请求体**: 原始文件字节(`Content-Type` 必须与脚本类型匹配)
- **适用场景**: 大于 100 KiB 的 notebook / 资源文件
### 3.8 `PUT /api/v1/scripts/{script_id}`
更新脚本**工作副本**。受 **owner + is_locked** 双重门禁:
- admin 总是允许
- owner 总是允许
- 非 owner + `is_locked == 0` → 允许
- 非 owner + `is_locked == 1` → **403**
- **请求体**: `{"content": "..."}`
### 3.9 `DELETE /api/v1/scripts/{script_id}`
软删脚本。**版本**(`Versions`)会被保留以供审计。门禁同 §3.8。
### 3.10 ScriptPayload 字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `script_id` | ULID | |
| `workspace_id` | ULID | |
| `current_object_id` | ULID | 当前工作副本指向的 `StorageObjects.storage_object_id` |
| `owner_user_id` | ULID | |
| `script_name` | string | |
| `script_type` | `python` \| `notebook` | |
| `visibility` | enum | |
| `status` | `active` \| `deleted` | |
| `relative_path` | string \| null | 例如 `users/alice/scripts/etl/train.py` |
| `content_hash` | string \| null | SHA-256 十六进制 |
| `size_bytes` | int | |
| `created_at` / `updated_at` | ISO-8601 | |
### 3.11 `POST /api/v1/scripts/{script_id}/versions`
发布一个**稳定版本**(immutable,绑定到 `S3_VERSION_BUCKET`)。门禁同 §3.8。
- **请求体**:
```json
{
"source_object_id": "01HXY...",
"release_note": "首次发布",
"visibility": "workspace"
}
```
- **行为**:
- 读 `source_object_id` 对应的工作副本内容,算 SHA-256
- 同 `content_hash` 已存在则返回 200 + `meta.reused = true`(去重)
- 否则把副本内容上 `versions` 桶,创建 `Versions` 行
- **响应**:
```json
{
"data": {
"versions_id": "01HXY...",
"version_no": 3,
"version_label": "v3.0",
"content_hash": "...",
"file_size_bytes": 2048,
"artifact_path": "s3://versions/<ws_id>/<ulid>",
"...": "..."
},
"meta": {"reused": false}
}
```
### 3.12 `GET /api/v1/scripts/{script_id}/versions`
列出该脚本的所有版本(倒序)。
### 3.13 `GET /api/v1/versions/{versions_id}`
单版本详情。
### 3.14 `DELETE /api/v1/versions/{versions_id}`
从调度候选中**隐藏**此版本(不删除对象存储里的对象)。门禁:**owner 校验基于所属 `Scripts` 的 owner**——即"按整本 script 判定",而非"按版本发布者判定"。
### 3.15 `POST /api/v1/versions/{versions_id}/download-url`
生成对象存储的 presigned download URL(走 S3 兼容协议,local 模式下该 endpoint 在 s3 模式才生效)。
- **请求体**:
```json
{"expires_seconds": 300}
```
- **响应**:
```json
{
"data": {
"storage_object_id": "...",
"presigned_url": "https://<gateway>/storage/<bucket>/<key>?X-Amz-...",
"method": "GET",
"expires_in_seconds": 300
}
}
```
---
## 四、调度
> 业务概念: `Schedules` 是 DAG 模板(nodes + edges),`ScheduleRuns` 是
> 触发产生的一次执行实例,**自带 snapshot 锁住当时的 DAG**,`ScheduleNodeRuns`
> 是 run 里每个 node 每次尝试的记录。
>
> 调度链路(架构 V3.1 §2.3):
> ```
> 手动: POST /run ─→ schedule_runs (queued) + outbox_events
> cron: Executor APScheduler tick → POST /run ─→ 同上
> │
> Orchestrator (poll outbox 0.25s) │
> → schedule_node_runs (queued) + outbox_events(job.node.execute)
> Worker (poll outbox) │
> → 执行 → schedule_node_runs (succeeded/failed) + outbox_events(job.node.finished)
> Orchestrator 收 finished → 推进下一个 node / 终结 run
> ```
### 4.1 `GET /api/v1/schedule-artifacts`
列出可绑定到节点的 `Versions`(DAG 画布下拉框的素材源)。
### 4.2 `POST /api/v1/cron/preview`
预览一个 cron 表达式的未来 5 次触发时间。
- **请求体**:
```json
{"expression": "0 0 * * *", "timezone": "Asia/Shanghai"}
```
### 4.3 `POST /api/v1/schedules/{schedule_id}/validate`
校验 DAG 拓扑(环路检测、孤立节点等)。
### 4.4 调度模板 CRUD
| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` | `/api/v1/schedules` | 列当前 workspace 的所有 schedule |
| `POST` | `/api/v1/schedules` | 创建(返回 201) |
| `GET` | `/api/v1/schedules/{id}` | 详情(含 nodes + edges) |
| `PUT` / `PATCH` | `/api/v1/schedules/{id}` | 改 cron / 时区 / 启用 / max_concurrency / failure_policy |
| `DELETE` | `/api/v1/schedules/{id}` | 软删 |
`CreateScheduleRequest` 字段:
```json
{
"schedule_name": "nightly-train",
"trigger_type": "cron",
"cron_expression": "0 0 * * *",
"timezone": "Asia/Shanghai",
"enabled": true,
"max_concurrency": 3,
"failure_policy": "stop"
}
```
### 4.5 节点 CRUD
| 方法 | 路径 | 说明 |
|---|---|---|
| `POST` | `/api/v1/schedules/{id}/nodes` | 加节点(必填 `versions_id` 绑定 Versions) |
| `PUT` | `/api/v1/schedules/{id}/nodes/{node_id}` | 改节点参数/版本引用/重试策略 |
| `DELETE` | `/api/v1/schedules/{id}/nodes/{node_id}` | 删节点 |
节点 `arguments` / `env_refs` 只存引用,不存明文密钥。
### 4.6 边 CRUD
| 方法 | 路径 | 说明 |
|---|---|---|
| `POST` | `/api/v1/schedules/{id}/edges` | 加边(`source_node_id` / `target_node_id`) |
| `PUT` | `/api/v1/schedules/{id}/edges/{edge_id}` | 改 `condition_expr` |
| `DELETE` | `/api/v1/schedules/{id}/edges/{edge_id}` | 删边 |
> ⚠ `condition_expr` 字段当前**仅落库,不参与执行判定**。DAG 只能表示依赖,
> 不能表达"父 node value > 0 才走 A 分支"等条件分支。
### 4.7 触发与查询
#### `POST /api/v1/schedules/{schedule_id}/run`
手动触发一次 run。
- **必填请求头**:`Idempotency-Key`(≥8 字符)
- **可选请求体**:`{"reason": "manual_run"}`(默认) | `{"reason": "cron"}`
- **响应 202**:
```json
{
"data": {
"run_id": "01HXY...",
"schedule_id": "...",
"trigger_type": "manual",
"run_status": "queued",
"queued_at": "...",
"schedule_snapshot": {"nodes": [...], "edges": [...]}
},
"meta": {"reused": false}
}
```
- 同 `Idempotency-Key` 已存在 → 返回原 run + `meta.reused = true`
- 同 key 但元数据不一致 → **409 Conflict**
- DAG 无效 / 节点 > 100 / 边 > 500 → **409** + 错误码 `SCHEDULE_DAG_INVALID`
#### `GET /api/v1/schedule-runs`
列出 run。可选 `?schedule_id=...` 与 `?status=queued|running|succeeded|failed|cancelled|timed_out` 过滤。
#### `GET /api/v1/schedule-runs/{run_id}`
单 run 详情 + 所有 `node_run`。
### 4.8 run 状态机
```
queued ──→ running ──┬─→ succeeded
├─→ failed
├─→ cancelled (未实现)
└─→ timed_out
```
---
## 五、数据资源
> 通用二进制资源(数据集、模型 checkpoint、任意文件)。Base 路径
> 前缀是 `/api/v1/data-resources`,**不**带脚本/notebook 的 owner 锁。
| 方法 | 路径 | 说明 |
|---|---|---|
| `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 |
| `DELETE` | `/api/v1/data-resources/{id}` | 软删 |
请求示例(上传):`POST /api/v1/data-resources/uploads`
```json
{
"file_name": "data.csv",
"content_type": "text/csv",
"expected_size_bytes": 1048576,
"expected_hash": "<optional sha256 hex>",
"idempotency_key": "client-uuid-or-similar"
}
```
**完整上传流程(前端应实现的模式)**:
```
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` 字段(JSON 体里走),内部走同一条 `AsyncStorageBackend.put`
路径,前端无需分两步。
---
## 六、管理后台
Base 前缀 `/api/v1/admin`。
| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` | `/api/v1/admin/employees` | 列员工(workspace 成员) |
| `POST` | `/api/v1/admin/employees` | 创建员工账号(返回 201) |
| `PATCH` | `/api/v1/admin/employees/{user_id}` | 改员工信息(角色/状态等) |
| `DELETE` | `/api/v1/admin/employees/{user_id}` | 软删员工 |
> 当前所有 admin 端点要求 `is_admin` 上下文标志,具体 token 校验流程
> 见 §一。
---
### 6.1 `POST /api/v1/admin/employees`
创建员工账号。
- **请求体字段**:
| 字段 | 类型 | 必填 | 限制 | 说明 |
|---|---|---|---|---|
| `username` | string | 是 | 2~64 字符 | 登录名,workspace 内唯一 |
| `display_name` | string | 是 | 1~100 字符 | 显示名称 |
| `email` | string | 否 | ≤255 字符 | 邮箱,全局唯一 |
| `role_code` | string | 否 | `admin` \| `developer` | 默认 `developer` |
| `password` | string | 是 | 8~72 字符 | 登录密码 |
- **密码说明**:
- 密码明文**不会**存入数据库,后端使用 bcrypt 哈希后保存到 `password_hash`。
- 请求体中 `password` 必填,长度必须在 8~72 字符之间,否则返回 `422`。
- 创建成功后的响应**不**包含 `password` 或 `password_hash`。
- **响应 201**:
```json
{
"request_id": "...",
"data": {
"user_id": "...",
"username": "...",
"display_name": "...",
"email": "...",
"status": "active",
"role_code": "developer",
"role_name": "...",
"created_at": "..."
},
"meta": {}
}
```
> `PATCH` / `DELETE` 员工接口**不**涉及密码字段,也不返回密码相关信息。
## 七、系统管理 (`/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 端点**。前端**不要**直接调用。
### 8.1 浏览器 → 用户打开 notebook
用户在前端点击某个 notebook,前端拼出 URL:
```
GET /jupyter/{workspace_id}/notebooks/{相对路径}.ipynb
GET /jupyter/{workspace_id}/lab/tree/{相对路径}.ipynb
GET /jupyter/{workspace_id}/api/contents/{相对路径}.ipynb
WS /jupyter/{workspace_id}/api/kernels/...
```
### 8.2 Nginx `auth_request` 鉴权
Nginx 收到上述请求后,**先**发一个内部子请求:
```
GET /internal-auth
Nginx: 抽 X-Original-Workspace-Id + X-Original-URI + Cookie + Authorization
GET /api/v1/auth/jupyter
Backend 流程:
1. verify_jwt_token (HS256, JWT_SECRET)
2. require_workspace_member (WorkspaceMembers JOIN)
3. extract_notebook_path (只对 /notebooks/*.ipynb 做 lock 校验)
4. check_notebook_is_locked (Scripts.is_locked + owner)
5. runtime_client.get_workspace / start_workspace (拿子进程地址 + token)
Backend 响应 200 + 响应头:
x-upstream-addr: <base_url>:<jupyter 子进程端口>
x-jupyter-internal-token: <子进程 token>
Nginx: auth_request_set 捕获这两个变量,proxy_pass 到子进程并注入
Authorization: token $jupyter_token
浏览器收到响应,**自始至终未接触 Jupyter Token**
```
### 8.3 鉴权失败码
| 状态 | 触发条件 |
|---|---|
| 401 | JWT 缺失/过期/校验失败 |
| 403 | 用户不在 workspace / notebook 被锁定且非 owner |
| 404 | workspace 不存在 |
| 503 | runtime 容器不可达(子进程启动失败) |
Nginx 把这些状态原样透传给浏览器,前端可在 `onerror` 里判断。
---
## 九、对象存储控制面
> 路径前缀 `/internal/v1/...`,**前端不要直接调用**。这是 backend 内部
> 异步消息处理(Schedule worker)用的 RPC 端点,经 in-process ASGI 直接
> 转发(`storage_app` 路由被 `app.include_router` 进同一个 backend 进程),
> 外部无法访问。
底层抽象:`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。
```json
{
"workspace_id": "...",
"user_id": "...",
"usage_type": "working_copy",
"file_name": "train.py",
"content_type": "text/x-python",
"expected_size_bytes": 1024,
"expected_hash": "<optional sha256 hex>",
"idempotency_key": "...",
"visibility": "private",
"is_immutable": false
}
```
返回 `{upload_id, status, upload_path, expires_at}`。`upload_path` 是
第 9.2 步要 PUT 的端点(本进程内 `/internal/v1/uploads/{upload_id}`)。
### 9.2 `PUT /internal/v1/uploads/{upload_id}`
完成上传(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。
> 历史:旧版本这一步是 `POST /uploads/{id}/complete`,靠
> presigned-PUT + head() 验证。已被 server-proxied PUT 取代。
### 9.3 `POST /internal/v1/uploads/{upload_id}/abort`
主动放弃。删除可能已经写了一半的对象字节,释放 `UploadSessions` 行。
### 9.4 `POST /internal/v1/objects`
**单步创建**(不走两步上传,字节 base64 进 JSON 体)。适用 < 100 KiB
对象(避免 multipart/大请求体的前端复杂度)。内部直接调
`AsyncStorageBackend.put(key, content, content_type=...,
metadata={"sha256": ...})`。
```json
{
"workspace_id": "...",
"user_id": "...",
"usage_type": "run_log",
"file_name": "log.txt",
"content_type": "text/plain",
"content_base64": "PHN0ZXAtY29udGVudD4=",
"visibility": "private",
"is_immutable": false,
"idempotency_key": "...",
"relative_path": null
}
```
### 9.5 `POST /internal/v1/objects/{storage_object_id}/download-url`
生成 presigned GET URL(s3 模式:`AsyncStorageBackend.get_url()`;
local 模式:目前抛 `NotImplementedError`,需要 native FS serving 配合
nginx 静态 location)。
### 9.6 `DELETE /internal/v1/objects/{storage_object_id}`
软删。`is_immutable == 1` 的对象拒绝删除。`move_to_trash` 走跨后端
`copy + delete`(同一进程内的两个 backend 实例)。
### 9.7 usage_type → 桶路由(自动)
| usage_type | 实际桶(env var) | 默认桶名 |
|---|---|---|
| `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 个子目录。
---
## 十、健康检查
| 方法 | 路径 | 用途 |
|---|---|---|
| `GET` | `/` | 简单服务标识 |
| `GET` | `/health/live` | 进程存活(不检查依赖) |
| `GET` | `/health/ready` | 依赖就绪(可选 TCP 探测列表) |
| `GET` | `/api/v1/health` | 公开健康检查(前端可访问) |
`/health/ready` 支持 `READINESS_TARGETS` 环境变量,逗号分隔的 `host:port`
列表,例如 `mysql:3306,s3:9000`,全部 TCP 通则返回 200,否则 503。
`STORAGE_BACKEND=local` 模式下不需要 S3 host,列表里删掉即可。
---
## 附录 A — 错误码参考
| HTTP | 业务码 / 含义 | 触发场景 |
|---|---|---|
| 400 | 参数错误 | Pydantic 校验失败 |
| 401 | 未鉴权 | JWT 缺失/无效 |
| 403 | 鉴权失败 | 非 workspace 成员 / `is_locked` 阻写 / **非系统管理员访问 `/api/v1/platform/*`** / 系统管理员自我移除 workspace 成员 |
| 404 | 不存在 | resource_id / script_id / schedule_id 找不到 |
| 409 | 冲突 | DAG 无效 / 同 idempotency_key 不同元数据 / 目标已存在 / `is_immutable` 阻删 / **workspace 末位 admin 保护** |
| 412 | 条件失败 | `source_object_id` 与当前工作副本不一致 |
| 413 | 太大 | 内容超过 100 MiB / 10 MiB |
| 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` |
| `Scripts.status` | `active` / `deleted` |
| `Schedules.trigger_type` | `manual` / `cron` / `api` |
| `Schedules.failure_policy` | `stop` / `continue` |
| `ScheduleRuns.run_status` | `queued` / `running` / `succeeded` / `failed` / `cancelled` / `timed_out` |
| `ScheduleNodeRuns.node_status` | `queued` / `running` / `succeeded` / `failed` / `skipped` / `cancelled` / `timed_out` |
| `UploadSessions.upload_status` | `created` / `uploading` / `completed` / `expired` / `aborted` / `failed` |
## 附录 C — 通用枚举字段
| 字段 | 取值 |
|---|---|
| `visibility` | `private` / `workspace` / `public` |
| `is_locked` | `0` / `1`(`Scripts` 表 TINYINT) |
---
## 附录 D — 跨域与 Cookie
- Nginx 同源代理,前端与 API 同源,不需要 CORS 配置。
- 鉴权通过 Cookie 或 `Authorization` 头携带(见 §一)。
- 上传类接口要求 `Idempotency-Key`,前端应在请求构造时就生成稳定 UUID
并缓存,失败重试时复用同一 key。