Files
model-platform/API.md
T
2026-08-13 09:35:27 +08:00

48 KiB

模型平台接口文档

本文档面向前端开发者与第三方集成方。所有接口的入口是 Nginx 网关(默认 http://localhost:8888),除 /api/v1/auth/jupyter 由 Nginx auth_request 自动调用,其他接口都通过 /api/v1/... 同源访问。

服务端基础 URL 示例: http://localhost:8888

通用响应外壳:

{
  "request_id": "01HXY...",
  "data": { ... },
  "meta": {}
}

错误响应为标准 HTTP 4xx / 5xx,body 为 {"detail": "..."}{"code": "...", "message": "...", "details": {}}

目录

  1. 鉴权
  2. 统一约定
  3. 脚本 / Notebook (/api/v1/scripts/...)
  4. 调度 (/api/v1/schedules/... + /api/v1/schedule-runs/...)
  5. 数据资源 (/api/v1/data-resources/...)
  6. 管理后台 (/api/v1/admin/...)
  7. 系统管理 (/api/v1/platform/...)
  8. Jupyter 路由 (Nginx auth_request)
  9. 对象存储控制面 (/internal/v1/...,同进程 RPC)
  10. 健康检查

一、鉴权

平台用 JWT (HS256)。登录后,前端在后续请求里任选一种携带方式:

  • Cookie(推荐用于浏览器):登录成功后后端种 Authorization 或自定义 cookie;前端无需手写。
  • Authorization: Bearer <token>(推荐用于脚本与第三方)。

JWT_SECRET 由后端从 Settings.jwt_secret 读取,前端不需要知道,只需要保证登录态带过来即可。

  • AuthorizationCookie 同时存在时,后端优先使用 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。

二、统一约定

类别 约定
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 成员
  • 请求体: 无
  • 响应:
    {
      "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='private'),因此空目录也能在 §3.1 树里出现并保留下来。

  • 请求体:

    {
      "directory_name": "etl",
      "parent_path": "scripts"
    }
    
    字段 必填 说明
    directory_name 目录名(单段,不能含 /)
    parent_path 父目录相对路径,空字符串或缺省表示用户根目录
  • 父目录存在性校验: 必须在 StorageObjects 存在 relative_path == scoped_prefix/{parent} 的行,或 relative_pathscoped_prefix/{parent}/ 开头。否则 404

  • 同名冲突: 已有 relative_path 完全相等的行(无论 file / directory) → 409uk_storage_workspace_path(workspace_id, storage_backend, path_hash) 唯一索引保证幂等。

  • 响应 201:

    {
      "request_id": "...",
      "data": {
        "storage_object_id": "01HXY...",
        "path": "scripts/etl",
        "name": "etl",
        "parent_path": "scripts"
      }
    }
    

3.3 DELETE /api/v1/workspace-directories?path=...

删除一个目录(以及目录下当前用户拥有的所有 Scripts,会触发 is_locked 校验)。

  • 查询参数:
    类型 必填 说明
    path string 相对路径,例如 scripts/etl
  • 响应:
    {
      "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=0StorageObjects 行。
  • 鉴权: workspace 成员
  • 查询参数:
    类型 必填 说明
    parent_path string 父目录相对路径,空字符串或缺省表示用户根目录
  • 谓词(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))
  • 响应:
    {
      "request_id": "...",
      "data": {
        "directories": [
          {"path": "scripts",            "name": "scripts", "parent_path": "",                "has_children": true},
          {"path": "scripts/etl",        "name": "etl",     "parent_path": "scripts",        "has_children": false},
          {"path": "scripts/etl/daily",  "name": "daily",   "parent_path": "scripts/etl",    "has_children": false}
        ]
      },
      "meta": {"directory_count": 3}
    }
    
    • 字段表(继承 §3.1):
      字段 类型 说明
      path string workspace 内相对路径
      name string path 的最后一段
      parent_path string 父目录相对路径,根目录用空串
      has_children bool 该目录下是否还有直接子目录(后端额外 EXISTS 查询,可为空目录为 false)
  • 空结果: 不返回 404,空目录列表即 directories: []
  • 错误: 401(未登录)/ 403(非 workspace 成员)同其他接口。

3.4 GET /api/v1/scripts

列出当前 workspace 内全部 active 脚本。不受 is_locked 影响(读路径不锁)。

  • 响应: dataScriptPayload 数组(见 §3.10)。

3.5 GET /api/v1/scripts/{script_id}

取单个脚本详情。

3.6 POST /api/v1/scripts

创建一个脚本(直接走 create_server_object 上传)。

  • 请求体:
    {
      "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_pathvisibility
  • 请求体: 原始文件字节(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 == 1403

  • 请求体: {"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
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。

  • 请求体:
    {
      "source_object_id": "01HXY...",
      "release_note": "首次发布",
      "visibility": "workspace"
    }
    
  • 行为:
    • source_object_id 对应的工作副本内容,算 SHA-256
    • content_hash 已存在则返回 200 + meta.reused = true(去重)
    • 否则把副本内容上 versions 桶,创建 Versions
  • 响应:
    {
      "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 模式才生效)。

  • 请求体:
    {"expires_seconds": 300}
    
  • 响应:
    {
      "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
  • 请求体:
    {"is_locked": true}
    
    字段 必填 说明
    is_locked 目标状态。true 锁定;false 解锁
  • 响应 200: data 为更新后的 ScriptPayload(§3.10)。
    {
      "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 次触发时间。

  • 请求体:
    {"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 字段:

{
  "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:
    {
      "data": {
        "run_id": "01HXY...",
        "schedule_id": "...",
        "trigger_type": "manual",
        "run_status": "queued",
        "queued_at": "...",
        "schedule_snapshot": {"nodes": [...], "edges": [...]}
      },
      "meta": {"reused": false}
    }
    
    • Idempotency-Key 已存在 → 返回原 run + meta.reused = true
    • 同 key 但元数据不一致 → 409 Conflict
    • DAG 无效 / 节点 > 100 / 边 > 500 → 409 + 错误码 SCHEDULE_DAG_INVALID

GET /api/v1/schedule-runs

列出 run。可选 ?schedule_id=...?status=queued|running|succeeded|failed|cancelled|timed_out 过滤。

GET /api/v1/schedule-runs/{run_id}

单 run 详情 + 所有 node_run

4.8 run 状态机

queued ──→ running ──┬─→ succeeded
                     ├─→ failed
                     ├─→ cancelled   (未实现)
                     └─→ timed_out

五、数据资源

通用二进制资源(数据集、模型 checkpoint、任意文件)。Base 路径 前缀是 /api/v1/data-resources,带脚本/notebook 的 owner 锁。

方法 路径 说明
POST /api/v1/data-resources/uploads 创建上传会话,返回 upload_id + upload_path
PUT /api/v1/data-resources/uploads/{upload_id} 上传字节(请求体即文件内容)
GET /api/v1/data-resources 列表(workspace 范围)
GET /api/v1/data-resources/{id} 详情
POST /api/v1/data-resources/{id}/download-url 生成 presigned GET URL
DELETE /api/v1/data-resources/{id} 软删

请求示例(上传):POST /api/v1/data-resources/uploads

{
  "file_name": "data.csv",
  "content_type": "text/csv",
  "expected_size_bytes": 1048576,
  "expected_hash": "<optional sha256 hex>",
  "idempotency_key": "client-uuid-or-similar"
}

完整上传流程(前端应实现的模式):

1. POST /uploads → {upload_id, upload_path, expires_at}
2. PUT  upload_path  with raw file bytes (Content-Type: application/octet-stream)
3. 服务器端走 backend.put() → 200 {data: StorageObjectPayload}

字节经过 backend 进程(server-proxied upload),最大 100 MiB,由 backend 直接调 AsyncStorageBackend.put() 写入存储(不再走 presigned PUT 直传)。 前端无需关心 S3 协议或签名。

小对象(<100 KiB)捷径:直接调 create_server_object 把字节 base64 放进 content_base64 字段(JSON 体里走),内部走同一条 AsyncStorageBackend.put 路径,前端无需分两步。


六、管理后台

Base 前缀 /api/v1/admin

方法 路径 说明
GET /api/v1/admin/employees 列员工(workspace 成员)
POST /api/v1/admin/employees 创建员工账号(返回 201)
PATCH /api/v1/admin/employees/{user_id} 改员工信息(角色/状态等)
DELETE /api/v1/admin/employees/{user_id} 软删员工

当前所有 admin 端点要求 is_admin 上下文标志,具体 token 校验流程 见 §一。


6.1 POST /api/v1/admin/employees

创建员工账号。

  • 请求体字段:
字段 类型 必填 限制 说明
username string 2~64 字符 登录名,workspace 内唯一
display_name string 1~100 字符 显示名称
email string ≤255 字符 邮箱,全局唯一
role_code string admin | developer 默认 developer
password string 8~72 字符 登录密码
  • 密码说明:

    • 密码明文不会存入数据库,后端使用 bcrypt 哈希后保存到 password_hash
    • 请求体中 password 必填,长度必须在 8~72 字符之间,否则返回 422
    • 创建成功后的响应包含 passwordpassword_hash
  • 响应 201:

    {
      "request_id": "...",
      "data": {
        "user_id": "...",
        "username": "...",
        "display_name": "...",
        "email": "...",
        "status": "active",
        "role_code": "developer",
        "role_name": "...",
        "created_at": "..."
      },
      "meta": {}
    }
    

PATCH / DELETE 员工接口涉及密码字段,也不返回密码相关信息。

七、系统管理 (/api/v1/platform/...)

平台级(跨 workspace)管理接口,用于管理员工、workspace 实体与 workspace 成员。 所有端点要求调用者是系统管理员——其 users.platform_role_id 指向 role_code='admin' 的角色行,且 users.status == 'active'。系统管理员判定 通过 GET /api/v1/auth/me 响应中的 data.user.is_system_admin 字段(详见 §一)。

方法 路径 说明
GET /api/v1/platform/employees 列全平台未软删员工;包含停用、锁定及无平台角色用户
POST /api/v1/platform/employees 创建平台员工账号(返回 201);不自动加入任何 workspace
PATCH /api/v1/platform/employees/{user_id} 改员工资料/状态/平台角色(仅系统管理员)
DELETE /api/v1/platform/employees/{user_id} 软删员工;级联软删其 workspace 成员关系(仅系统管理员)
GET /api/v1/platform/workspaces 列 workspace(active/archived);已软删的过滤掉
POST /api/v1/platform/workspaces 创建 workspace(返回 201);创建者自动成为 admin 成员
GET /api/v1/platform/workspaces/{workspace_id} 单个 workspace(含已 disabled 的,用于恢复)
PATCH /api/v1/platform/workspaces/{workspace_id} 改 workspace 字段;status 仅允许 active/archived
DELETE /api/v1/platform/workspaces/{workspace_id} 软删 workspace;级联软删其成员
GET /api/v1/platform/workspaces/{workspace_id}/members 列成员
POST /api/v1/platform/workspaces/{workspace_id}/members 添加成员(返回 201)
PATCH /api/v1/platform/workspaces/{workspace_id}/members/{user_id} 改成员 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 + system.manage 的最后系统管理员保护(仅系统管理员)

不变量:

  • 每个 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,后者会级联软删所有成员。
  • workspace 与成员列表接口静默 pageSize=100 上限,无客户端分页参数(YAGNI);GET /employees 按契约返回全部未软删员工,不设隐藏上限。
  • 跨 workspace 操作需要 ?workspace_id= query 参数,与 /api/v1/admin/...(workspace 内成员管理)不要混淆。

7.1 POST /api/v1/platform/workspaces

创建 workspace;创建者(当前系统管理员)自动成为该 workspace 的 admin 成员。

  • 请求体字段:
字段 类型 必填 限制 说明
workspace_code string regex ^[a-z0-9-]{3,32}$(类似 git repo 名) 创建后冻结,不可改
workspace_name string 1~150 字符 显示名称
quota_bytes int ≥0,默认 0 配额字节数,0 表示无配额
description string ≤1000 字符
  • 服务端自动生成字段(不接收):

    • workspace_id(ULID)
    • active_root_uri(s3://workspaces/{workspace_id}/)
    • status("active")
    • created_by(当前管理员 user_id)
    • created_at / updated_at(DB 自动)
  • 响应 201:见下 §7.2 WorkspacePayload

7.2 GET /api/v1/platform/workspaces/{workspace_id} / WorkspacePayload

  • 响应 200:
    {
      "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_idworkspace_codeactive_root_uricreated_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}

软删除。允许从 activearchived 状态调用。

  • 副作用:
    • 该 workspace 行:status='disabled'is_deleted=1deleted_at=NOW()
    • 级联:所有未删除的 workspace_members 行同步 is_deleted=1deleted_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

  • 无请求体或查询参数。

  • 包含 activedisabledlocked 状态及未分配平台角色的员工。

  • users.is_deleted != 0 的员工不会返回。

  • 不设隐藏数量上限,结果按 created_atuser_id 排序。

  • role_coderole_name 表示 users.platform_role_id 对应的平台角色;未分配时均为 null

  • 响应 200:

    {
      "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 哈希保存;响应不包含 passwordpassword_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;未传入时均为 nullmeta 为空对象。

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 行
  • 禁止通过该端点修改:usernamepasswordpassword_hashplatform_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_codedeveloper(即降级系统管理员身份)→ 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.11 DELETE /api/v1/platform/employees/{user_id}

软删除平台员工;级联软删其所有 workspace_members 行。调用者必须是系统管理员。

  • 行为:

    • 设置 users.status='disabled'users.is_deleted=1users.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:

    {
      "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:

    {
      "request_id": "...",
      "data": [
        {
          "role_id": "01HXY...",
          "role_code": "admin",
          "role_name": "管理员",
          "is_builtin": true,
          "permission_codes": [
            "dashboard.view", "experiment.all", "experiment.own",
            "resource.personal", "resource.public.manage",
            "resource.public.upload", "schedule.all", "schedule.own",
            "script.build", "script.public.manage", "system.manage",
            "system.view"
          ]
        },
        {
          "role_id": "01HXY...",
          "role_code": "developer",
          "role_name": "开发人员",
          "is_builtin": true,
          "permission_codes": [
            "dashboard.view", "experiment.own", "resource.personal",
            "schedule.own", "script.build", "script.public.manage"
          ]
        }
      ],
      "meta": { "count": 2 }
    }
    

7.13 GET /api/v1/platform/roles/{role_code}/permissions

获取单个平台角色的当前 permission_codes。调用者必须是系统管理员。

  • 用途:角色权限管理面板加载右侧"已分配"列;也用于前端做 diff 显示。

  • 角色不存在 → 404;角色存在但 role_scope != 'platform'(例如只有 workspace 角色行匹配) → 404。

  • 响应 200:

    {
      "request_id": "...",
      "data": {
        "role_id": "01HXY...",
        "role_code": "admin",
        "role_name": "管理员",
        "is_builtin": true,
        "permission_codes": [
          "dashboard.view", "experiment.all", "experiment.own",
          "resource.personal", "resource.public.manage",
          "resource.public.upload", "schedule.all", "schedule.own",
          "script.build", "script.public.manage", "system.manage",
          "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.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.viewsystem.manage,否则 → 409 "admin 角色必须保留 system.view 与 system.manage 权限"。
    3. admin 角色:permission_codes禁止system.* 项 → 422 "非 admin 角色不能拥有 system.* 权限: [...]"。menu permission 只控前端展示,后端 /api/v1/platform/* 鉴权仍按 role_code == "admin";若让 developer 拿到 system.manage,前端会渲染"系统管理"入口但所有 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:

    {
      "request_id": "...",
      "data": {
        "role_id": "01HXY...",
        "role_code": "admin",
        "role_name": "管理员",
        "is_builtin": true,
        "permission_codes": ["dashboard.view", "system.manage", "system.view"]
      },
      "meta": {}
    }
    
  • 当前 seed 的 permission_code 全集(来自迁移 f6a7b8c9d0e1,与 migrations/data/migrate_system_json.py::PERMISSION_NAMES 真值对齐,不要在客户端另造一份):

permission_code module_code admin developer
dashboard.view dashboard
script.build script
script.public.manage script
schedule.own schedule
schedule.all schedule
experiment.own experiment
experiment.all experiment
resource.personal resource
resource.public.upload resource
resource.public.manage resource
system.view system
system.manage system

与现有 7.x 端点的语义差异(避免 reviewer 误读):

  • 本端点不修改 users.platform_role_id,只调整 role_permissions 关联表。
  • "清空 developer 的全部权限"是合法操作;只有 adminsystem.* 强制约束。
  • 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 里判断。


九、对象存储控制面

路径前缀 /internal/v1/...,前端不要直接调用。这是 backend 内部 异步消息处理(Schedule worker)用的 RPC 端点,经 in-process ASGI 直接 转发(storage_app 路由被 app.include_router 进同一个 backend 进程), 外部无法访问。

底层抽象:common.storage.AsyncStorageBackend(put/get/delete/exists/stat/ list/get_url/copy)。按 settings.storage_backend 选实现:"s3" 走 S3-兼容服务,"local"LOCAL_STORAGE_BASE_DIR 子目录。

9.1 POST /internal/v1/uploads

创建上传会话。Idempotency-Key 必填,同 key + 同元数据 → 复用;同 key + 不同元数据 → 409。

{
  "workspace_id": "...",
  "user_id": "...",
  "usage_type": "working_copy",
  "file_name": "train.py",
  "content_type": "text/x-python",
  "expected_size_bytes": 1024,
  "expected_hash": "<optional sha256 hex>",
  "idempotency_key": "...",
  "visibility": "private",
  "is_immutable": false
}

返回 {upload_id, status, upload_path, expires_at}upload_path 是 第 9.2 步要 PUT 的端点(本进程内 /internal/v1/uploads/{upload_id})。

9.2 PUT /internal/v1/uploads/{upload_id}

完成上传(server-proxied PUT)。请求体即原始字节,Content-Type: application/octet-stream。后端 await request.body() 读字节 → 校验 size + sha256 → 调 await backend.put(key, bytes, content_type=..., metadata={"sha256": ...}) → 写 StorageObjects 行 → 标 session 为 completed。最大 100 MiB。

历史:旧版本这一步是 POST /uploads/{id}/complete,靠 presigned-PUT + head() 验证。已被 server-proxied PUT 取代。

9.3 POST /internal/v1/uploads/{upload_id}/abort

主动放弃。删除可能已经写了一半的对象字节,释放 UploadSessions 行。

9.4 POST /internal/v1/objects

单步创建(不走两步上传,字节 base64 进 JSON 体)。适用 < 100 KiB 对象(避免 multipart/大请求体的前端复杂度)。内部直接调 AsyncStorageBackend.put(key, content, content_type=..., metadata={"sha256": ...})

{
  "workspace_id": "...",
  "user_id": "...",
  "usage_type": "run_log",
  "file_name": "log.txt",
  "content_type": "text/plain",
  "content_base64": "PHN0ZXAtY29udGVudD4=",
  "visibility": "private",
  "is_immutable": false,
  "idempotency_key": "...",
  "relative_path": null
}

9.5 POST /internal/v1/objects/{storage_object_id}/download-url

生成 presigned GET URL(s3 模式:AsyncStorageBackend.get_url(); local 模式:目前抛 NotImplementedError,需要 native FS serving 配合 nginx 静态 location)。

9.6 DELETE /internal/v1/objects/{storage_object_id}

软删。is_immutable == 1 的对象拒绝删除。move_to_trash 走跨后端 copy + delete(同一进程内的两个 backend 实例)。

9.7 usage_type → 桶路由(自动)

usage_type 实际桶(env var) 默认桶名
working_copy, public_script, data_resource, snapshot S3_WORKSPACE_BUCKET workspaces
version_artifact S3_VERSION_BUCKET versions
run_log, run_result S3_RUN_LOG_BUCKET run-logs
(soft-delete target) S3_TRASH_BUCKET trash

Workspaces.artifact_bucket 非空,优先用 per-workspace 桶(覆盖 usage_type 路由)。

桶在 STORAGE_BACKEND=s3 时是 4 个独立 S3 bucket,在 STORAGE_BACKEND=local 时是 LOCAL_STORAGE_BASE_DIR 下的 4 个子目录。


十、健康检查

方法 路径 用途
GET / 简单服务标识
GET /health/live 进程存活(不检查依赖)
GET /health/ready 依赖就绪(可选 TCP 探测列表)
GET /api/v1/health 公开健康检查(前端可访问)

/health/ready 支持 READINESS_TARGETS 环境变量,逗号分隔的 host:port 列表,例如 mysql:3306,s3:9000,全部 TCP 通则返回 200,否则 503。 STORAGE_BACKEND=local 模式下不需要 S3 host,列表里删掉即可。


附录 A — 错误码参考

HTTP 业务码 / 含义 触发场景
400 参数错误 Pydantic 校验失败
401 未鉴权 JWT 缺失/无效
403 鉴权失败 非 workspace 成员 / is_locked 阻写 / 非系统管理员访问 /api/v1/platform/* / 系统管理员自我移除 workspace 成员
404 不存在 resource_id / script_id / schedule_id 找不到
409 冲突 DAG 无效 / 同 idempotency_key 不同元数据 / 目标已存在 / is_immutable 阻删 / workspace 末位 admin 保护
412 条件失败 source_object_id 与当前工作副本不一致
413 太大 内容超过 100 MiB / 10 MiB
422 语义错误 文件名非法 / cron 表达式非法 / 路径逃逸 / workspace_code 不匹配 ^[a-z0-9-]{3,32}$ / status="disabled" 走 PATCH
500 内部错误 DB / 存储不可达

附录 B — 状态枚举

类型 取值
Workspaces.status active / archived / disabled(disabled 由 DELETE 设置,PATCH 不允许设)
WorkspaceMembers.member_status active / disabled / locked
StorageObjects.usage_type data_resource / version_artifact / snapshot / run_log / run_result / working_copy / public_script
StorageObjects.object_status available / deleted
StorageObjects.visibility private / workspace / public
Scripts.status active / deleted
Schedules.trigger_type manual / cron / api
Schedules.failure_policy stop / continue
ScheduleRuns.run_status queued / running / succeeded / failed / cancelled / timed_out
ScheduleNodeRuns.node_status queued / running / succeeded / failed / skipped / cancelled / timed_out
UploadSessions.upload_status created / uploading / completed / expired / aborted / failed

附录 C — 通用枚举字段

字段 取值
visibility private / workspace / public
is_locked 0 / 1(Scripts 表 TINYINT)

  • Nginx 同源代理,前端与 API 同源,不需要 CORS 配置。
  • 鉴权通过 Cookie 或 Authorization 头携带(见 §一)。
  • 上传类接口要求 Idempotency-Key,前端应在请求构造时就生成稳定 UUID 并缓存,失败重试时复用同一 key。