1247 lines
55 KiB
Markdown
1247 lines
55 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/objects`,服务间 RPC + Token 鉴权)](#九对象存储控制面)
|
||
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` 对象额外携带以下三个字段:
|
||
> - `role_code: string | null` —— 用户的**平台角色**(`users.platform_role_id` 指向的 Roles 行的 `role_code`),取值为 `admin` / `developer` / `null`(未分配)。**注意:本字段同时也是该用户在所有 workspace 中的角色**——workspace 角色始终继承自平台角色,本端点不再返回 workspace 级独立角色码。
|
||
> - `is_system_admin: bool` —— 派生自 `users.platform_role_id` 指向的角色 `role_code == 'admin'` 且用户状态为 `active`。前端据此决定是否渲染"系统管理"入口。
|
||
> - `permissions: string[]` —— 当前用户通过其平台角色(`platform_role_id`)间接持有的菜单权限码列表(`permission_code`),按字典序排列;未分配平台角色时为空数组。前端据此过滤菜单与 `<RequirePermission>` 路由守卫。**仅控制前端展示,不参与后端 endpoint 鉴权**——后端鉴权继续由 `system_admin_context`(`role_code == 'admin'`)与 workspace membership 负责。详见 §7.12-7.14。
|
||
|
||
### 1.1 登录 / 会话
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| `POST` | `/api/v1/auth/login` | 校验用户名密码,种 `access_token` HttpOnly Cookie,返回 `user` + `workspaces` |
|
||
| `POST` | `/api/v1/auth/logout` | 清除 Cookie(幂等) |
|
||
| `GET` | `/api/v1/auth/me` | 返回当前登录用户与可访问 workspace 列表 |
|
||
|
||
### 1.2 `PATCH /api/v1/auth/me` — 修改本人资料
|
||
|
||
当前登录用户修改自己的显示名 / 邮箱。不需要 `workspace_id`。
|
||
|
||
- **请求体**(至少提供一个字段;多余字段 → 422):
|
||
|
||
| 字段 | 类型 | 限制 | 说明 |
|
||
|---|---|---|---|
|
||
| `display_name` | string | 1~100 | trim 后写入 |
|
||
| `email` | string \| null | ≤255 | trim 后写入;空字符串归一为 `null`;与其他未软删用户冲突 → 409 "邮箱已存在" |
|
||
|
||
- 不可通过本端点修改:`username`、`password`、`status`、`platform_role_id`。
|
||
- **响应 200**:`data.user` 与 `GET /me` 中的 `user` 形状一致(含 `role_code` / `is_system_admin` / `permissions`)。
|
||
|
||
### 1.3 `POST /api/v1/auth/password` — 本人修改密码
|
||
|
||
- **请求体**:
|
||
|
||
| 字段 | 类型 | 限制 | 说明 |
|
||
|---|---|---|---|
|
||
| `current_password` | string | 1~72 | 当前密码 |
|
||
| `new_password` | string | 8~72 | 新密码 |
|
||
|
||
- 当前密码错误 → 400 "当前密码不正确"。
|
||
- 新密码与当前密码相同 → 400 "新密码不能与当前密码相同"。
|
||
- **成功后清除会话 Cookie**,客户端须引导用户重新登录。
|
||
- **响应 200**:`data.password_changed = true`。
|
||
|
||
---
|
||
|
||
## 二、统一约定
|
||
|
||
| 类别 | 约定 |
|
||
|---|---|
|
||
| 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` (**legacy / 全量视图**)
|
||
|
||
> **Deprecated**: 新代码请走 §3.3.1 (按 `parent_path` 单层)。该接口仍保留供调试 / 兼容使用,
|
||
> 谓词已统一为 `object_status='available' AND is_deleted=0`,与 §3.3.1 保持一致。
|
||
|
||
列出当前用户在 workspace 内的**整个目录树**(扁平数组)。
|
||
|
||
- **来源**: 显式 `StorageObjects` 行 (`object_type='directory'`,见 §3.2) **并入** 从 `Scripts.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`
|
||
|
||
创建一个**目录**。后端会落一行 `StorageObjects(object_type='directory', storage_backend='rustfs', storage_uri='inline://directory/{path}', visibility='public')`,因此空目录也能在 §3.1 树里出现并保留下来。
|
||
|
||
> 目录行默认 `visibility='public'`(非 `private`)。目录是结构性导航行,
|
||
> 默认 public 使同一 workspace 内其他成员可以浏览彼此的目录结构(目录树
|
||
> 跨 owner 可见);文件级私密仍由 §3.4 / §五 的 visibility 过滤兜底
|
||
> —— 其他 owner 的 `private` 脚本 / 数据资源不会返回。
|
||
|
||
- **请求体**:
|
||
```json
|
||
{
|
||
"directory_name": "etl",
|
||
"parent_path": "scripts"
|
||
}
|
||
```
|
||
| 字段 | 必填 | 说明 |
|
||
|---|---|---|
|
||
| `directory_name` | 是 | 目录名(单段,不能含 `/`) |
|
||
| `parent_path` | 否 | 父目录相对路径,空字符串或缺省表示用户根目录 |
|
||
|
||
- **父目录存在性校验**: 必须在 `StorageObjects` 存在 `relative_path == scoped_prefix/{parent}` 的行,或 `relative_path` 以 `scoped_prefix/{parent}/` 开头。否则 **404**。
|
||
- **同名冲突**: 已有 `relative_path` 完全相等的行(无论 file / directory) → **409**。冲突判定由应用层 `SELECT ... FOR UPDATE` 完成;`idx_storage_workspace_path(workspace_id, storage_backend, path_hash)` 仅作为查找索引,不再提供 DB 级唯一性兜底。
|
||
- **响应 201**:
|
||
```json
|
||
{
|
||
"request_id": "...",
|
||
"data": {
|
||
"storage_object_id": "01HXY...",
|
||
"path": "scripts/etl",
|
||
"name": "etl",
|
||
"parent_path": "scripts",
|
||
"owner_user_id": "01HXX..."
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.3 `DELETE /api/v1/workspace-directories?path=...`
|
||
|
||
删除一个目录(以及目录下当前用户拥有的所有 `Scripts`,**会触发 is_locked 校验**)。
|
||
`StorageObjects` 行移到 trash bucket(`settings.s3_trash_bucket`),`object_status` 置为 `deleted`。
|
||
|
||
- **查询参数**:
|
||
| 名 | 类型 | 必填 | 说明 |
|
||
|---|---|---|---|
|
||
| `path` | string | 是 | 相对路径,例如 `scripts/etl` |
|
||
- **响应**:
|
||
```json
|
||
{
|
||
"data": {
|
||
"path": "scripts/etl",
|
||
"status": "deleted",
|
||
"deleted_scripts": 3,
|
||
"versions_preserved": true
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.3.1 `GET /api/v1/workspace-directories?parent_path=...`
|
||
|
||
列出指定父目录下的**直接子目录**(单层),用于前端懒加载。**新代码请走本接口**;§3.1 仅作为 legacy / 全量保留。
|
||
|
||
- **行为**: `parent_path` 为空字符串或缺省 → 用户根目录;非空 → 该父目录的直接子目录(workspace-relative)。仅返回 `object_status='available' AND is_deleted=0` 的 `StorageObjects` 行。
|
||
- **鉴权**: workspace 成员
|
||
- **owner 作用域**: `owner_user_id` 缺省时 scope 为**当前请求者**本人根目录(`scoped_prefix = workspace/{me}`);传 `owner_user_id` 时 scope 为该 owner 的根目录(`scoped_prefix = workspace/{owner_user_id}`),用于跨 owner 浏览目录树(见 §3.4 visibility 模型)。该接口本身不施加 visibility 过滤——目录行默认 `visibility='public'`(见 §3.2),跨 owner 均可见。
|
||
- **查询参数**:
|
||
| 名 | 类型 | 必填 | 说明 |
|
||
|---|---|---|---|
|
||
| `parent_path` | string | 否 | 父目录相对路径,空字符串或缺省表示用户根目录 |
|
||
| `owner_user_id` | string | 否 | 目标 owner 的 user_id;缺省=请求者本人。指定后 scope 到 `workspace/{owner_user_id}/{parent_path}` |
|
||
- **谓词(SQL 等价)**: `relative_path LIKE '<prefix>/%' AND relative_path NOT LIKE '<prefix>/%/%'`,其中 `prefix = scoped_prefix/{parent_path}`,索引走 `idx_storage_workspace_relative_path(workspace_id, relative_path(255))`。
|
||
- **响应**:
|
||
```json
|
||
{
|
||
"request_id": "...",
|
||
"data": {
|
||
"directories": [
|
||
{"path": "scripts", "name": "scripts", "parent_path": "", "owner_user_id": "01HXX...", "has_children": true},
|
||
{"path": "scripts/etl", "name": "etl", "parent_path": "scripts", "owner_user_id": "01HXX...", "has_children": false},
|
||
{"path": "scripts/etl/daily", "name": "daily", "parent_path": "scripts/etl", "owner_user_id": "01HXX...", "has_children": false}
|
||
]
|
||
},
|
||
"meta": {"directory_count": 3}
|
||
}
|
||
```
|
||
- 字段表(继承 §3.1):
|
||
| 字段 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `path` | string | workspace 内相对路径 |
|
||
| `name` | string | `path` 的最后一段 |
|
||
| `parent_path` | string | 父目录相对路径,根目录用空串 |
|
||
| `owner_user_id` | string | 该目录行所属 owner 的 user_id(`owner_user_id` 参数缺省时=请求者本人) |
|
||
| `has_children` | bool | 该目录下是否还有直接子目录(后端额外 `EXISTS` 查询,可为空目录为 `false`) |
|
||
- **空结果**: 不返回 404,空目录列表即 `directories: []`。
|
||
- **错误**: 401(未登录)/ 403(非 workspace 成员)同其他接口。
|
||
|
||
### 3.4 `GET /api/v1/scripts`
|
||
|
||
列出脚本,按 **owner 作用域 + visibility 过滤**返回。不受 is_locked 影响(读路径不锁)。
|
||
|
||
- **owner 作用域**: `owner_user_id` 缺省=当前请求者本人,scope 到 `workspace/{me}/...`;传 `owner_user_id` 时 scope 到 `workspace/{owner_user_id}/...`,用于跨 owner 浏览他人脚本。
|
||
- **visibility 过滤(统一)**: 非 admin 请求者只返回 `owner_user_id == me OR visibility IN (workspace, public)`;admin 请求者跳过过滤返回全部。
|
||
- **owner=me(缺省)**: scope 是我的子树,行都是我的 → `owner==me` 恒成立 → **含我的 private 脚本** ✓
|
||
- **owner=other**: scope 是他人的子树,`owner==me` 不成立 → 只剩其 `workspace/public` 脚本(排除他人的 `private`) ✓
|
||
- 即"本人可见自己全部;他人只见其 workspace/public",私密仅在 owner==me 时可见。
|
||
- **非递归**: 仅返回 `parent_path` 下的**直接子级**脚本(懒加载用);子目录脚本需带 `parent_path` 再次请求。
|
||
- **查询参数**:
|
||
| 名 | 类型 | 必填 | 说明 |
|
||
|---|---|---|---|
|
||
| `parent_path` | string | 否 | 父目录相对路径,空串/缺省=该 owner 根目录下的一级脚本 |
|
||
| `owner_user_id` | string | 否 | 目标 owner;缺省=请求者本人 |
|
||
| `keyword` | string | 否 | 名称模糊匹配 |
|
||
- **响应**: `data` 为 `ScriptPayload` 数组(见 §3.10),每条带 `owner_user_id`。
|
||
|
||
### 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。
|
||
`StorageObjects` 行移到 trash bucket(`settings.s3_trash_bucket`),`object_status` 置为 `deleted`。
|
||
|
||
### 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` | |
|
||
| `is_locked` | boolean | 是否锁定。`PUT /scripts/{id}`(§3.8) 受此字段门禁;可通过 §3.16 切换 |
|
||
| `relative_path` | string \| null | 例如 `workspace/01HXX.../scripts/etl/train.py`(以 `user_id` 为作用域) |
|
||
| `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
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.16 `PATCH /api/v1/scripts/{script_id}/lock`
|
||
|
||
切换脚本的 `is_locked` 状态。**只切换锁**,不修改脚本内容。
|
||
|
||
- **鉴权**: admin 或 `owner_user_id == 当前用户`(沿用 `require_script_modify_access`)。其余一律 **404**。
|
||
- **请求体**:
|
||
```json
|
||
{"is_locked": true}
|
||
```
|
||
| 字段 | 必填 | 说明 |
|
||
|---|---|---|
|
||
| `is_locked` | 是 | 目标状态。`true` 锁定;`false` 解锁 |
|
||
- **响应 200**: `data` 为更新后的 `ScriptPayload`(§3.10)。
|
||
```json
|
||
{
|
||
"request_id": "...",
|
||
"data": { "...ScriptPayload 字段...": "is_locked: false" },
|
||
"meta": {}
|
||
}
|
||
```
|
||
- **行为**: 行级锁 (`SELECT ... FOR UPDATE`) 防止并发切换;提交后立即生效,影响后续 §3.8 `PUT /scripts/{id}` 的门禁判定。
|
||
- **错误码**:
|
||
| 码 | 含义 |
|
||
|---|---|
|
||
| 404 | 脚本不存在 / 非当前用户无权访问(不区分,避免暴露存在性) |
|
||
|
||
---
|
||
|
||
## 四、调度
|
||
|
||
> 业务概念: `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}` | 上传字节(请求体即文件内容) |
|
||
| `POST` | `/api/v1/data-resources/uploads/{upload_id}/bind` | 绑定已完成上传为数据资源 |
|
||
| `GET` | `/api/v1/data-resources` | 列表(owner 作用域 + visibility 过滤) |
|
||
| `GET` | `/api/v1/data-resources/{id}` | 详情 |
|
||
| `GET` | `/api/v1/data-resources/{id}/content` | 同源流式读取文件字节(预览/下载) |
|
||
| `GET` | `/api/v1/data-resources/{id}/preview` | 表格抽样预览(csv/tsv, `limit` 默认 100) |
|
||
| `POST` | `/api/v1/data-resources/{id}/download-url` | 生成 presigned GET URL |
|
||
| `DELETE` | `/api/v1/data-resources/{id}` | 软删 |
|
||
|
||
**同名冲突**:同一 `workspace` 内、同一目录(`target_path` 相等)、同一 `owner` 下,`resource_name` 重复提交绑定返回 `409`。不同目录或不同 `owner` 允许重名。重新绑定同一 `upload_id`(`storage_object_id` 已落库)走 idempotent 复用路径,不视为冲突。
|
||
|
||
字节归档到 trash bucket(`settings.s3_trash_bucket`)。
|
||
|
||
请求示例(上传):`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`
|
||
路径,前端无需分两步。
|
||
|
||
### 五.1 `GET /api/v1/data-resources`
|
||
|
||
列出数据资源,按 **owner 作用域 + visibility 过滤**返回(与 §3.4 `GET /scripts` 同一套统一语义)。
|
||
|
||
- **owner 作用域**: `owner_user_id` 缺省=当前请求者本人,scope 到 `object_key` 前缀 `{workspace_id}/{me}/...`;传 `owner_user_id` 时 scope 到 `{workspace_id}/{owner_user_id}/...`。
|
||
- **visibility 过滤(统一)**: 非 admin 请求者只返回 `owner_user_id == me OR visibility IN (workspace, public)`;admin 请求者跳过过滤返回全部。语义同 §3.4——owner=me 含自己的 private;owner=other 只见其 workspace/public。
|
||
- **非递归**: 仅返回 `parent_path` 下的**直接子级**资源(懒加载用)。
|
||
- **查询参数**:
|
||
| 名 | 类型 | 必填 | 说明 |
|
||
|---|---|---|---|
|
||
| `parent_path` | string | 否 | 父目录相对路径,空串/缺省=该 owner 根目录下的一级资源 |
|
||
| `owner_user_id` | string | 否 | 目标 owner;缺省=请求者本人 |
|
||
| `visibility` | string | 否 | `workspace` \| `public` \| `private`,二次过滤 |
|
||
| `keyword` | string | 否 | 名称模糊匹配 |
|
||
- **响应**: `data` 为 `ResourcePayload` 数组,每条带 `owner_user_id`。
|
||
|
||
---
|
||
|
||
## 六、管理后台
|
||
|
||
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 /workspaces/{id}/members`**:该端点对**系统管理员(任意
|
||
> workspace)**与**该 workspace 的活跃成员**(`workspace_members.is_deleted=0`
|
||
> 且 `member_status='active'`)均开放。这是为了让普通(非 admin)用户能在
|
||
> 脚本目录树里渲染同 workspace 其他成员的折叠分组(跨 owner 浏览,见 §3.4)。
|
||
> 其余 members 写端点(POST/PATCH/DELETE members)仍仅限系统管理员。
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| `GET` | `/api/v1/platform/employees` | 列全平台未软删员工(cursor 分页 + `q` 搜索,见 §7.0) |
|
||
| `POST` | `/api/v1/platform/employees` | 创建平台员工账号(返回 201);不自动加入任何 workspace |
|
||
| `PATCH` | `/api/v1/platform/employees/{user_id}` | 改员工资料/状态/平台角色(仅系统管理员) |
|
||
| `POST` | `/api/v1/platform/employees/{user_id}/reset-password` | 重置员工密码(仅系统管理员;不需要旧密码) |
|
||
| `DELETE` | `/api/v1/platform/employees/{user_id}` | 软删员工;级联软删其 workspace 成员关系(仅系统管理员) |
|
||
| `GET` | `/api/v1/platform/workspaces` | 列 workspace(`active`/`archived`)(cursor 分页 + `q` 搜索,见 §7.0) |
|
||
| `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` | 列成员(**系统管理员或该 workspace 活跃成员**;为跨 owner 目录树提供成员名册,见 §7 intro 例外) |
|
||
| `POST` | `/api/v1/platform/workspaces/{workspace_id}/members` | 添加成员(返回 201) |
|
||
| `PATCH` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 改成员 `member_status`;**不能改 role_code**(workspace 角色继承自平台角色) |
|
||
| `DELETE` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 软删成员 |
|
||
| `GET` | `/api/v1/platform/roles` | 列全部 `role_scope='platform'` 的角色及其 `permission_codes`(仅系统管理员) |
|
||
| `GET` | `/api/v1/platform/roles/{role_code}/permissions` | 单个平台角色的 `permission_codes`(仅系统管理员) |
|
||
| `PATCH` | `/api/v1/platform/roles/{role_code}/permissions` | 整体替换角色权限集合;对 `admin` 角色强制保留 `system:view` 的最后系统管理员保护(仅系统管理员) |
|
||
|
||
> **不变量**:
|
||
> - 每个 workspace 必须始终保留至少一个 `admin` 角色的活跃成员;对最后 admin 做降级 / 停用 / 删除 → 409。
|
||
> - `PATCH /employees/{user_id}` 降级 admin → developer 时同样触发 workspace last-admin 守卫(因为 workspace 角色继承自 platform 角色,降级会级联到所有活跃 membership);platform 必须始终保留至少一个 `active` 系统管理员;对最后系统管理员做降级 / 停用 / 删除 → 409。
|
||
> - 系统管理员不能通过 `DELETE .../members/{self}` 把自己移除(403)。唯一退出方式是 `DELETE /workspaces/{id}` 软删整个 workspace,后者会级联软删所有成员。
|
||
> - `GET /employees` 与 `GET /workspaces` 支持 cursor 分页与关键字搜索(见 §7.0);成员列表仍静默 `pageSize=100` 上限。
|
||
> - 跨 workspace 操作**不**需要 `?workspace_id=` query 参数,与 `/api/v1/admin/...`(workspace 内成员管理)不要混淆。
|
||
|
||
### 7.0 列表分页约定(employees / workspaces)
|
||
|
||
`GET /api/v1/platform/employees` 与 `GET /api/v1/platform/workspaces` 使用 **keyset cursor** 分页(无 `offset` / `page`)。
|
||
|
||
| Query | 类型 | 默认 | 说明 |
|
||
|---|---|---|---|
|
||
| `limit` | int | `10` | 每页条数,范围 1~200 |
|
||
| `cursor` | string | 无 | 上一页返回的 `meta.next_cursor`;缺省为第一页;非法值 → 400 |
|
||
| `q` | string | 无 | 关键字搜索。employees 匹配 `display_name`/`username`/`email`;workspaces 匹配 `workspace_name`/`workspace_code`/`description` |
|
||
|
||
响应 `meta`:
|
||
|
||
```json
|
||
{
|
||
"limit": 10,
|
||
"page_count": 10,
|
||
"total_count": 156,
|
||
"has_more": true,
|
||
"next_cursor": "..."
|
||
}
|
||
```
|
||
|
||
- `total_count`:当前筛选条件下的总条数(用于页码展示)。
|
||
- `next_cursor`:无下一页时为 `null`。
|
||
- 排序键:`(created_at ASC, id ASC)`。前端用 cursor 栈实现「上一页 / 下一页 + 已访问页码」;不支持任意跳到未访问过的深页。
|
||
|
||
### 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`
|
||
|
||
添加成员。
|
||
|
||
> **Workspace 角色继承平台角色**:本端点不接受 `role_code`,新成员的 `workspace_members.role_id` 始终等于其 `users.platform_role_id` 指向的角色行。**不可填** `system_admin`(那是用户级身份,不是 workspace 角色)。要改某成员的 workspace 角色,请改 `users.platform_role_id`,即 `PATCH /api/v1/platform/employees/{user_id}`。
|
||
|
||
- **请求体字段**:
|
||
|
||
| 字段 | 类型 | 必填 | 限制 | 说明 |
|
||
|---|---|---|---|---|
|
||
| `user_id` | string | 是 | 26 字符 ULID | |
|
||
|
||
- 服务端默认 `member_status='active'`。
|
||
- 用户不存在或已软删除 → 404;用户状态不是 `active` → 409;用户**尚未分配平台角色**(`users.platform_role_id IS NULL`)→ 409 "目标用户尚未分配平台角色,无法加入 workspace"。
|
||
- 用户已是该 workspace 成员 → 409 "用户已是该 workspace 成员;workspace 角色继承自平台角色,要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code"。
|
||
- 新员工必须先通过 `POST /api/v1/platform/employees` 建立账号(可以同时传 `role_code=admin|developer`)。
|
||
|
||
### 7.6 `PATCH /api/v1/platform/workspaces/{workspace_id}/members/{user_id}`
|
||
|
||
修改成员的状态。**不能通过本端点修改 role_code**——workspace 角色始终继承自 `users.platform_role_id`;要改角色请 `PATCH /api/v1/platform/employees/{user_id}`。
|
||
|
||
- **请求体字段**(全部可选):
|
||
|
||
| 字段 | 类型 | 限制 | 说明 |
|
||
|---|---|---|---|
|
||
| `member_status` | string | `active` \| `disabled` \| `locked` | 停用 / 锁定最后 admin → 409 |
|
||
|
||
- 提交 `role_code` 字段 → 422(Pydantic `extra='forbid'`),不是静默忽略。
|
||
|
||
### 7.7 `DELETE /api/v1/platform/workspaces/{workspace_id}/members/{user_id}`
|
||
|
||
软删除成员。
|
||
|
||
- **自我移除保护**:`user_id == 当前管理员 user_id` → 403 "系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace"
|
||
- **末位 admin 保护**:若删除的是最后一个 `admin` 角色活跃成员 → 409
|
||
- 不存在的成员 → 404
|
||
|
||
### 7.8 `GET /api/v1/platform/employees`
|
||
|
||
返回全平台所有未软删除员工。调用者必须是系统管理员;已认证但不是系统管理员时返回 `403`。
|
||
|
||
- 无请求体或查询参数。
|
||
- 包含 `active`、`disabled`、`locked` 状态及未分配平台角色的员工。
|
||
- `users.is_deleted != 0` 的员工不会返回。
|
||
- 不设隐藏数量上限,结果按 `created_at`、`user_id` 排序。
|
||
- `role_code`、`role_name` 表示 `users.platform_role_id` 对应的平台角色;未分配时均为 `null`。
|
||
|
||
- **响应 200**:
|
||
```json
|
||
{
|
||
"request_id": "...",
|
||
"data": [
|
||
{
|
||
"user_id": "01HXY...",
|
||
"username": "developer",
|
||
"display_name": "开发人员",
|
||
"email": "developer@example.com",
|
||
"status": "active",
|
||
"role_code": null,
|
||
"role_name": null,
|
||
"created_at": "2026-08-06T12:00:00.000"
|
||
}
|
||
],
|
||
"meta": {"count": 1}
|
||
}
|
||
```
|
||
|
||
### 7.9 `POST /api/v1/platform/employees`
|
||
|
||
只创建平台员工账号,不创建任何 `workspace_members` 记录。调用者必须是系统管理员;已认证但不是系统管理员时返回 `403`。
|
||
|
||
- **请求体字段**:
|
||
|
||
| 字段 | 类型 | 必填 | 限制 | 说明 |
|
||
|---|---|---|---|---|
|
||
| `username` | string | 是 | 2~64 字符 | 全平台唯一登录名 |
|
||
| `display_name` | string | 是 | 1~100 字符 | 显示名称 |
|
||
| `email` | string | 否 | ≤255 字符 | 邮箱,全平台唯一 |
|
||
| `password` | string | 是 | 8~72 字符 | 登录密码 |
|
||
| `role_code` | string | 否 | `admin` \| `developer` | 平台角色;不传或 `null` 表示**不分配角色**(用户无法加入任何 workspace,见 §7.5) |
|
||
|
||
- 新用户状态固定为 `active`;`role_code` 决定 `users.platform_role_id` 指向 `role_code='admin'` / `'developer'` 的 Roles 行,未传则 `platform_role_id=NULL`。
|
||
- 平台**仅**有 `admin` / `developer` 两个平台角色;不存在第三个角色枚举值。
|
||
- 密码使用 bcrypt 哈希保存;响应不包含 `password` 或 `password_hash`。
|
||
- 用户名或邮箱重复 → 409;`role_code` 取值非法 → 422。
|
||
- 创建成功后,可调用 `POST /api/v1/platform/workspaces/{workspace_id}/members` 将用户加入指定 workspace(会要求用户已有 `platform_role_id`,否则 409)。
|
||
|
||
- **响应 201**:字段与 §7.8 的员工元素一致;当 `role_code` 传入时,`role_code` / `role_name` 反映 `platform_role_id`;未传入时均为 `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`**(降级为"无平台角色"须走单独的内部流程,前端不要尝试)。
|
||
- `role_code` 改动会**立即级联同步**该用户**所有活跃 workspace 成员行**的 `workspace_members.role_id`(`PATCH /employees` 在事务内 `UPDATE workspace_members SET role_id=:new WHERE user_id=:uid AND is_deleted=0`)。否则 `/me` 返回的 `workspaces[].role_code` 与下游 `request_context` 都会读到过期角色。本端点是调整任何成员 workspace 角色的**唯一**入口。
|
||
- 自保护:
|
||
- 修改自身 `status` 为非 `active` → 409 "不能停用当前登录账号"。
|
||
- 修改自身 `role_code` 为 `developer`(即降级系统管理员身份)→ 409 "不能降级自身管理员角色"。
|
||
- 最后系统管理员保护:当目标用户当前为 active 系统管理员,本次变更会让其离开"active 系统管理员"集合(降级角色 / 停用账号)时,平台必须仍保留至少一名 active 系统管理员,否则 → 409 "platform 必须保留至少一个 active 系统管理员"。**自保护在前、last-admin 计数在后**(参考 CLAUDE.md 工程笔记)。
|
||
- **workspace last-admin 守卫**(降级路径):因为 `role_code` 改动会级联同步 `workspace_members.role_id`,把 `admin` 降级为 `developer` 时必须**额外**检查该用户在**每个**他是唯一 active admin 的 workspace 中仍有替补 admin,否则 → 409,错误信息列出将失去 admin 的 `workspace_code` 列表;提示"请先在这些 workspace 中指定其他 admin,再降级该用户"。`developer → admin` 升级路径不受此守卫约束(只增不减)。
|
||
- 目标用户不存在或已软删除 → 404 "用户不存在";`role_code` 对应的角色行不存在 → 422。
|
||
|
||
- **响应 200**:返回更新后的 `PlatformEmployeePayload`,`role_code` / `role_name` 反映最新的 `platform_role_id`。
|
||
|
||
### 7.10.1 `POST /api/v1/platform/employees/{user_id}/reset-password`
|
||
|
||
系统管理员为指定员工设置新密码(不校验旧密码)。JWT 无服务端吊销列表,已有会话在过期前仍可用;对方下次登录须用新密码。
|
||
|
||
- **请求体**:
|
||
|
||
| 字段 | 类型 | 限制 | 说明 |
|
||
|---|---|---|---|
|
||
| `new_password` | string | 8~72 | 新密码;bcrypt 哈希后写入 `password_hash` |
|
||
|
||
- 目标不存在或已软删 → 404 "用户不存在"。
|
||
- **响应 200**:`data = { "user_id": "...", "password_reset": true }`。
|
||
|
||
### 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": {}
|
||
}
|
||
```
|
||
|
||
### 7.12 `GET /api/v1/platform/roles`
|
||
|
||
列出所有 `role_scope='platform'` 的角色及其当前 `permission_codes`。调用者必须是系统管理员。
|
||
|
||
- 用于平台管理员配置页加载左侧"角色"下拉与权限矩阵。
|
||
- 返回结果按 `role_code` 升序。
|
||
- `permission_codes` 与 DB 中 `role_permissions` 关联表的活跃(`is_deleted = 0`)行一致,按字典序排列;空集合表示该角色当前没有任何菜单权限。
|
||
- `is_builtin` 反映 `roles.is_builtin`;当前 seed 的 `admin` / `developer` 均为 `1`。
|
||
|
||
- **响应 200**:
|
||
```json
|
||
{
|
||
"request_id": "...",
|
||
"data": [
|
||
{
|
||
"role_id": "01HXY...",
|
||
"role_code": "admin",
|
||
"role_name": "管理员",
|
||
"is_builtin": true,
|
||
"permission_codes": [
|
||
"dashboard:view", "schedule:view", "script:view",
|
||
"system:project:view", "system:user:view", "system:view"
|
||
]
|
||
},
|
||
{
|
||
"role_id": "01HXY...",
|
||
"role_code": "developer",
|
||
"role_name": "开发人员",
|
||
"is_builtin": true,
|
||
"permission_codes": [
|
||
"dashboard:view", "schedule:view", "script:view"
|
||
]
|
||
}
|
||
],
|
||
"meta": { "count": 2 }
|
||
}
|
||
```
|
||
|
||
### 7.13 `GET /api/v1/platform/roles/{role_code}/permissions`
|
||
|
||
获取单个平台角色的当前 `permission_codes`。调用者必须是系统管理员。
|
||
|
||
- 用途:角色权限管理面板加载右侧"已分配"列;也用于前端做 diff 显示。
|
||
|
||
- 角色不存在 → 404;角色存在但 `role_scope != 'platform'`(例如只有 `workspace` 角色行匹配) → 404。
|
||
|
||
- **响应 200**:
|
||
```json
|
||
{
|
||
"request_id": "...",
|
||
"data": {
|
||
"role_id": "01HXY...",
|
||
"role_code": "admin",
|
||
"role_name": "管理员",
|
||
"is_builtin": true,
|
||
"permission_codes": [
|
||
"dashboard:view", "schedule:view", "script:view",
|
||
"system:project:view", "system:user:view", "system:view"
|
||
]
|
||
},
|
||
"meta": {}
|
||
}
|
||
```
|
||
|
||
### 7.14 `PATCH /api/v1/platform/roles/{role_code}/permissions`
|
||
|
||
整体替换指定平台角色的 `permission_codes`(diff-based 写入,见下文)。调用者必须是系统管理员。
|
||
|
||
> **本端点只控制前端菜单可见性**——不修改 `system_admin_context` 的鉴权判定(`role_code == "admin"` 始终等价于"拥有所有平台菜单权限")。若需调整 API 鉴权,请改 `backend.api.platform.system_admin_context`,不要绕过本端点。
|
||
|
||
- **请求体字段**:
|
||
|
||
| 字段 | 类型 | 必填 | 限制 | 说明 |
|
||
|---|---|---|---|---|
|
||
| `permission_codes` | string[] | 是 | 0~64 项;内部去重(首次出现优先);元素必须是现存且未软删的 `permissions.permission_code` | 完整替换集合(非 patch);传 `[]` 表示清空该角色的全部菜单权限 |
|
||
|
||
- **守卫顺序(load-bearing,不可调换)**:
|
||
1. 角色不存在 / `role_scope != 'platform'` → 404。
|
||
2. **`admin` 角色**:提交的 `permission_codes` 必须包含 `system:view`,否则 → 409 "admin 角色必须保留 system:view 权限"。
|
||
3. **非 `admin` 角色**:`permission_codes` 中**禁止**含 `system:*` 项 → 422 "非 admin 角色不能拥有 system:* 权限: [...]"。menu permission 只控前端展示,后端 `/api/v1/platform/*` 鉴权仍按 `role_code == "admin"`;若让 developer 拿到 `system:view`,前端会渲染"系统管理"入口但所有 platform API 调用 403,UX 割裂。
|
||
4. 任意 `permission_code` 不在 `permissions` 表活跃行中 → 422 "未知的 permission_code: [...]"。
|
||
5. 写入策略:diff-based —— 只 soft-delete `current \ new` 的关联,只 INSERT `new \ current` 的关联。重复提交同 payload 是 no-op;包含原有 codes 的 patch 不会触发 `(role_id, permission_id)` 主键冲突。**严禁**先全量 soft-delete 再全量 INSERT(会 `IntegrityError`,因为软删行仍占主键 slot)。
|
||
- 写入后,响应 `data.permission_codes` 为本次写入后的活跃集合,与再次 `GET §7.13` 完全一致。
|
||
|
||
- **响应 200**:
|
||
```json
|
||
{
|
||
"request_id": "...",
|
||
"data": {
|
||
"role_id": "01HXY...",
|
||
"role_code": "admin",
|
||
"role_name": "管理员",
|
||
"is_builtin": true,
|
||
"permission_codes": ["dashboard:view", "system:view"]
|
||
},
|
||
"meta": {}
|
||
}
|
||
```
|
||
|
||
- **当前 seed 的 permission_code 全集**(由 baseline `e1f2a3b4c5d6_rebuild_baseline` 直接写入 MySQL,不要在客户端另造一份):
|
||
|
||
| `permission_code` | `module_code` | admin | developer |
|
||
|---|---|:-:|:-:|
|
||
| `dashboard:view` | dashboard | ✓ | ✓ |
|
||
| `script:view` | script | ✓ | ✓ |
|
||
| `schedule:view` | schedule | ✓ | ✓ |
|
||
| `system:view` | system | ✓ | |
|
||
| `system:user:view` | system | ✓ | |
|
||
| `system:project:view` | system | ✓ | |
|
||
|
||
> **与现有 7.x 端点的语义差异**(避免 reviewer 误读):
|
||
> - 本端点不修改 `users.platform_role_id`,只调整 `role_permissions` 关联表。
|
||
> - "清空 developer 的全部权限"是合法操作;只有 `admin` 受 `system:*` 强制约束。
|
||
> - `role_code` 不是 `permission_code`,前端不要用前者去判断菜单可见性。
|
||
|
||
---
|
||
|
||
## 八、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` 里判断。
|
||
|
||
---
|
||
|
||
## 九、对象存储控制面
|
||
|
||
> **范围**:Schedule worker 调用 backend 写 `run_log` / `run_result` 用的
|
||
> 单端点 RPC。**前端不要直接调用,也不要把这个路径用于任何用户输入**。
|
||
> 历史上有 6 个 `/internal/v1/*` 端点,经过 P0-1 修复后只剩这 1 个;
|
||
> 删除的端点全部已迁移到 JWT 保护的 `/api/v1/data-resources/*` 与
|
||
> `/api/v1/scripts/*` 路由(见 §五、§三)。
|
||
|
||
底层抽象:`common.storage.AsyncStorageBackend`(`put/get/delete/exists/stat/
|
||
list/get_url/copy`)。按 `settings.storage_backend` 选实现:`"s3"` 走
|
||
S3-兼容服务,`"local"` 走 `LOCAL_STORAGE_BASE_DIR` 子目录。
|
||
|
||
### 鉴权(P0-1 后)
|
||
|
||
所有 `/internal/v1/*` 路由都会校验请求头里的 service token:
|
||
|
||
```
|
||
X-Internal-Service-Token: <settings.internal_service_token>
|
||
```
|
||
|
||
- backend 与 schedule 必须把同一个值注入到 `INTERNAL_SERVICE_TOKEN` 环境变量。
|
||
- 缺失或不相符 → `401`。
|
||
- backend 配置为空 → `503`(`internal service token not configured`)。
|
||
|
||
### 9.1 `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": true,
|
||
"idempotency_key": "...",
|
||
"relative_path": null
|
||
}
|
||
```
|
||
|
||
### 9.2 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 个子目录。
|
||
|
||
### 9.3 已删除的端点(P0-1 收纳)
|
||
|
||
| 旧端点 | 替代路由 | 说明 |
|
||
|---|---|---|
|
||
| `POST /internal/v1/uploads` | `POST /api/v1/data-resources/uploads`(JWT) | 前端走公开路径 |
|
||
| `PUT /internal/v1/uploads/{id}` | `PUT /api/v1/data-resources/uploads/{id}` | 同上 |
|
||
| `POST /internal/v1/uploads/{id}/abort` | (客户端取消即可) | 无后端状态 |
|
||
| `POST /internal/v1/objects/{id}/download-url` | 各自的公开路由生成 presigned URL | download URL 由公开路由返回 |
|
||
| `DELETE /internal/v1/objects/{id}` | 公开路由的删除操作 | 与 data-resource 联动 |
|
||
|
||
---
|
||
|
||
## 十、健康检查
|
||
|
||
| 方法 | 路径 | 用途 |
|
||
|---|---|---|
|
||
| `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。
|