diff --git a/API.md b/API.md new file mode 100644 index 0000000..dd7f1c0 --- /dev/null +++ b/API.md @@ -0,0 +1,630 @@ +# 模型平台接口文档 + +> 本文档面向**前端开发者与第三方集成方**。所有接口的入口是 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. [Jupyter 路由 (Nginx `auth_request`)](#七jupyter-路由) +8. [对象存储控制面 (`/internal/v1/...`,同进程 RPC)](#八对象存储控制面) +9. [健康检查](#九健康检查) + +--- + +## 一、鉴权 + +平台用 **JWT (HS256)**。登录后,前端在后续请求里**任选一种**携带方式: + +- **Cookie**(推荐用于浏览器):登录成功后后端种 `Authorization` 或自定义 cookie;前端无需手写。 +- **`Authorization: Bearer `**(推荐用于脚本与第三方)。 + +`JWT_SECRET` 由后端从 `Settings.jwt_secret` 读取,前端不需要知道,只需要保证登录态带过来即可。 + +- 当 `Authorization` 与 `Cookie` 同时存在时,后端**优先**使用 `Authorization`。 +- 缺失或过期 → HTTP `401`。 +- 有效但用户不在 workspace → HTTP `403`(由 Nginx `auth_request` 透传给客户端)。 + +--- + +## 二、统一约定 + +| 类别 | 约定 | +|---|---| +| 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` + +创建一个**逻辑目录**(RustFS 上是隐式前缀,无需落对象)。 + +- **请求体**: + ```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 形式上传大文件(走 presigned 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,绑定到 `RUSTFS_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//", + "...": "..." + }, + "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}` + +从调度候选中**隐藏**此版本(不删除 RustFS 对象)。门禁:**owner 校验基于所属 `Scripts` 的 owner**——即"按整本 script 判定",而非"按版本发布者判定"。 + +### 3.15 `POST /api/v1/versions/{versions_id}/download-url` + +生成 RustFS 的 presigned download URL。 + +- **请求体**: + ```json + {"expires_seconds": 300} + ``` +- **响应**: + ```json + { + "data": { + "storage_object_id": "...", + "presigned_url": "https:///storage//?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` | 创建上传会话,返回 presigned PUT URL + `upload_id` | +| `POST` | `/api/v1/data-resources/uploads/{upload_id}/complete` | 完成上传(写 `StorageObjects` 行) | +| `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": "", + "idempotency_key": "client-uuid-or-similar" +} +``` + +**完整上传流程(前端应实现的模式)**: +``` +1. POST /uploads → {upload_id, presigned_url} +2. PUT presigned_url with file bytes +3. POST /uploads/{upload_id}/complete +4. 服务器完成 → 200 {data: StorageObjectPayload} +``` + +**小对象(<100 KiB)捷径**:直接调 `create_server_object` 把字节 base64 放进 +`content_base64` 字段(走 `StorageClient.create_server_object`),前端无需 +走 presigned 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 校验流程 +> 见 §一。 + +--- + +## 七、Jupyter 路由 + +> **本节是 Nginx 行为,不是直接 HTTP 端点**。前端**不要**直接调用。 + +### 7.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/... +``` + +### 7.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: : + x-jupyter-internal-token: <子进程 token> + ↓ +Nginx: auth_request_set 捕获这两个变量,proxy_pass 到子进程并注入 + Authorization: token $jupyter_token + ↓ +浏览器收到响应,**自始至终未接触 Jupyter Token** +``` + +### 7.3 鉴权失败码 + +| 状态 | 触发条件 | +|---|---| +| 401 | JWT 缺失/过期/校验失败 | +| 403 | 用户不在 workspace / notebook 被锁定且非 owner | +| 404 | workspace 不存在 | +| 503 | runtime 容器不可达(子进程启动失败) | + +Nginx 把这些状态原样透传给浏览器,前端可在 `onerror` 里判断。 + +--- + +## 八、对象存储控制面 + +> 路径前缀 `/internal/v1/...`,**前端不要直接调用**。这是 backend 内部 +> 异步消息处理(Schedule worker)用的 RPC 端点,经 `StorageClient` HTTP +> 客户端访问。Backend 通过 ASGI `auth_request_set` 路由转发,外部无法 +> 访问。 + +### 8.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": "", + "idempotency_key": "..." +} +``` + +返回 `{upload_id, bucket_name, object_key, presigned_url, expires_in_seconds}`。 + +### 8.2 `POST /internal/v1/uploads/{upload_id}/complete` + +完成上传。从 RustFS 读 HEAD → 校验 hash → 写 `StorageObjects` 行。 + +### 8.3 `POST /internal/v1/uploads/{upload_id}/abort` + +主动放弃。释放 `UploadSessions` 行,对象不入库。 + +### 8.4 `POST /internal/v1/objects` + +**单步创建**(不走 presigned PUT,字节随请求体直传)。适用 < 100 KiB 对象。 + +```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": "..." +} +``` + +### 8.5 `POST /internal/v1/objects/{storage_object_id}/download-url` + +生成 presigned GET URL。 + +### 8.6 `DELETE /internal/v1/objects/{storage_object_id}` + +软删。`is_immutable == 1` 的对象拒绝删除。 + +### 8.7 usage_type → 桶路由(自动) + +| usage_type | 实际桶(env var) | 默认桶名 | +|---|---|---| +| `working_copy`, `public_script`, `data_resource`, `snapshot` | `RUSTFS_WORKSPACE_BUCKET` | `workspaces` | +| `version_artifact` | `RUSTFS_VERSION_BUCKET` | `versions` | +| `run_log`, `run_result` | `RUSTFS_RUN_LOG_BUCKET` | `run-logs` | + +若 `Workspaces.artifact_bucket` 非空,优先用 per-workspace 桶(覆盖 usage_type 路由)。 + +--- + +## 九、健康检查 + +| 方法 | 路径 | 用途 | +|---|---|---| +| `GET` | `/` | 简单服务标识 | +| `GET` | `/health/live` | 进程存活(不检查依赖) | +| `GET` | `/health/ready` | 依赖就绪(可选 TCP 探测列表) | +| `GET` | `/api/v1/health` | 公开健康检查(前端可访问) | + +`/health/ready` 支持 `READINESS_TARGETS` 环境变量,逗号分隔的 `host:port` +列表,例如 `mysql:3306,rustfs:9000`,全部 TCP 通则返回 200,否则 503。 + +--- + +## 附录 A — 错误码参考 + +| HTTP | 业务码 / 含义 | 触发场景 | +|---|---|---| +| 400 | 参数错误 | Pydantic 校验失败 | +| 401 | 未鉴权 | JWT 缺失/无效 | +| 403 | 鉴权失败 | 非 workspace 成员 / `is_locked` 阻写 | +| 404 | 不存在 | resource_id / script_id / schedule_id 找不到 | +| 409 | 冲突 | DAG 无效 / 同 idempotency_key 不同元数据 / 目标已存在 / `is_immutable` 阻删 | +| 412 | 条件失败 | `source_object_id` 与当前工作副本不一致 | +| 413 | 太大 | 内容超过 100 MiB / 10 MiB | +| 422 | 语义错误 | 文件名非法 / cron 表达式非法 / 路径逃逸 | +| 500 | 内部错误 | DB / 存储不可达 | + +## 附录 B — 状态枚举 + +| 类型 | 取值 | +|---|---| +| `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。