Files

1247 lines
55 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模型平台接口文档
> 本文档面向**前端开发者与第三方集成方**。所有接口的入口是 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。