Wiki
REST API
uVersion 服务器 HTTP 端点参考:认证、仓库、文件、锁、评论、关注列表、生产看板、构建、管理。
uVersion 服务器暴露一个 JSON HTTP API。调用通过
JWT(JSON Web Token)认证,也就是服务器在你登录时交给你、
之后你在每个请求中回传的会话令牌。本页记录了
桌面客户端、编辑器插件以及 uversion CLI 所使用的端点。
你可以直接调用它们,将 uVersion 集成到你自己的内部工具中
(自制仪表盘、审计脚本、Webhook 等)。
它并未覆盖整个 API:存在若干路由族,此处并未描述。 清单见 未涵盖的部分 一节。
约定
基础 URL
所有记录的路径都相对于你实例的 URL。示例使用
https://uversion.mygamestudio.com。请替换为你自己的。
必需的标头
| 标头 | 值 |
|---|---|
Authorization | Bearer <jwt>,用于除 /api/auth/* 和 /api/server-info(以及 /health)之外的每个 /api/* 路由 |
Content-Type | 带 JSON 主体的 POST/PUT 用 application/json。二进制上传(构建文件)用 application/octet-stream。 |
Accept | 推荐 application/json(服务器默认返回 JSON) |
两种响应格式,你必须知道自己在读哪一种
服务器的响应格式不是 一种 而是两种,混淆它们是开始一次集成时 代价最高的错误。
1. 已认证路由(除 /api/auth/* 外的所有 /api/*)
以一个信封响应:
{
"success": true,
"data": { /* payload */ }
}
出错时,这些相同的路由响应:
{
"success": false,
"error": "No write permission on this repository"
}
success 字段始终存在。data 在成功时存在,
error 在失败时存在,二者绝不同时出现。
2. 认证路由(/api/auth/login、
/refresh、/validate、/logout、/register、
/change-password)不使用这个信封。它们返回
裸对象,没有 success 也没有 data:
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": { "id": 12, "username": "alice", ... },
"must_change_password": false
}
而它们的错误是一个只有单个字段、不含 success 的对象:
{ "error": "Invalid credentials" }
实际后果:在 /api/auth/login 上,令牌读取自 .token,
而非 .data.token。查询 .data.token 的脚本会得到
null,且没有任何可见的错误。
最后,GET /api/files/{repo_id}/content 两者都不返回:它是文件的
原始二进制内容,原样返回。
分页
没有 通用的分页约定:每个路由有自己的,或者
根本没有。不要假定 limit、total 或 has_more。
| 路由 | 接受的参数 | 响应的形态 |
|---|---|---|
GET /api/files/{repo_id}/history |
limit(默认 50)、offset(默认 0)、path |
提交的扁平数组。没有 total,没有 has_more:当数组包含的元素少于 limit 时,你就到达了末尾。 |
GET /api/repositories |
仅 include_inactive(且仅对超级管理员被尊重) |
扁平数组。limit 和 offset 都不会被读取。 |
GET /api/locks/{repo_id}/status |
无 | 仓库所有锁的扁平数组。 |
GET /api/files/{repo_id}/snapshot |
commit_hash,必需 |
文件的扁平数组。 |
GET /api/admin/audit |
page 和 per_page,不是 limit/offset,另有过滤器(user_id、action、entity_type、from、to) |
仅限超级管理员。很好地说明了通用约定的缺失:它是唯一按页码分页的路由。 |
对于此处未列出的任何路由,请认为它在一次调用中返回其全部结果。
速率限制
没有通用的速率限制:一个 Unreal 项目有数千个文件, 批量操作(获取、释放锁)会不断触发限流。 因此你可以大批量地调用 API。
只有一个路由受限,/api/auth/login:
每个用户名每 15 分钟 5 次失败尝试。成功的登录
不计入。
令牌吊销
每个会话令牌带有一个 tv 字段(token version),它反映数据库中的
users.token_version 列。服务器在每个请求中比较二者。
一次 POST /api/auth/logout 调用、管理员的一次密码重置,
或一个账户的停用都会递增此值,从而使该用户的
所有现有令牌立即 在其所有机器上失效。
Curl
用于从终端调用 API。注意 .token:
/api/auth/login 的响应是裸对象,没有要穿过的 .data。
TOKEN=$(curl -s -X POST https://uversion.mygamestudio.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret"}' | jq -r '.token')
# 而已认证路由则确实使用该信封:
curl -s -H "Authorization: Bearer $TOKEN" \
https://uversion.mygamestudio.com/api/repositories | jq '.data'
认证
提醒: 本节的任何路由都不使用
{"success", "data"} 信封。它们返回裸对象,其错误采用
{"error": "..."} 的形式。
POST /api/auth/login
认证一个用户并返回一个有效期 30 天的会话令牌。
Body:
{
"username": "alice",
"password": "secret"
}
Response 200:
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": 12,
"username": "alice",
"email": "alice@mygamestudio.com",
"role": "lead"
},
"must_change_password": false
}
没有 expires_at 字段:有效期读取自令牌本身,
或从服务器配置推断(默认 30 天)。
不要忽略 must_change_password。 当账户仍在使用一个临时
密码时,此字段为 true:即安装程序为初始管理员账户生成的密码,
或管理员在重置时刚设置的密码。不查看它的客户端会让用户
无限期地停留在那个临时密码上。期望的行为是,在任何其他操作之前,
立即重定向到 POST /api/auth/change-password。
错误:
401:无效的凭据或已停用的账户429:在 15 分钟窗口内该用户名已达到 5 次失败尝试
服务器总是执行密码的 Argon2id 校验,账户不存在时也会针对一个虚拟 哈希执行。因此响应在两种情况下耗时相同,这就阻止了 通过为请求计时来推测某账户是否存在。
POST /api/auth/refresh
无需再次经过密码即可更新会话令牌。
标头: Authorization: Bearer <当前令牌>。无 body。
Response 200: 与 /login 完全相同的形态
(token、user、must_change_password),并带一个全新的令牌。
错误:
401:无效或过期的令牌、已停用的账户,或过时的token_version
POST /api/auth/logout
使当前用户在其所有机器上的 所有令牌 失效,办法是递增
users.token_version。他将不得不在各处重新登录:桌面客户端、
编辑器插件和 CLI 均在其列。
Response 200: { "logged_out": true }。
POST /api/auth/validate
检查一个令牌是否仍然有效,并返回它所对应的用户。该检查
也涵盖 is_active 和 token_version,因此一个被吊销的令牌
即使尚未过期也会被拒绝。
注意 body: 它不是一个对象,而是 一个裸 JSON 字符串, 也就是用双引号包围的令牌。
curl -X POST https://uversion.mygamestudio.com/api/auth/validate \
-H "Content-Type: application/json" \
-d '"eyJhbGciOiJIUzI1NiIs..."'
Response 200: 一个裸用户对象。
{
"id": 12,
"username": "alice",
"email": "alice@mygamestudio.com",
"role": "lead"
}
既没有 valid,也没有 expires_at,也没有 refreshed_token:
有效性读取自 HTTP 代码(200 或 401),而更新通过
/api/auth/refresh 进行,绝不通过此路由。
POST /api/auth/register
此路由默认拒绝。 除非运营者在服务器配置中明确启用,
否则开放注册是禁用的;否则响应为
403 并带 {"error": "Open registration is disabled; contact your administrator"}。
在正常运行中,账户通过管理创建:
POST /api/admin/users,或管理面板的用户标签页。
不要构建依赖 /register 的集成。
POST /api/auth/change-password
更改当前用户的密码。递增 token_version,从而
使所有之前的令牌失效,包括刚刚用于发出该调用的那个。
仓库
GET /api/repositories
列出当前用户可访问的仓库,由权限表过滤。对某仓库
没有任何权限规则的用户看不到它。admin 和
lead 角色能看到所有仓库。
查询参数: 只有一个,include_inactive(布尔值,默认
false),且仅对超级管理员被尊重。
既没有 limit 也没有 offset:该路由返回整个列表。
Response 200:
{
"success": true,
"data": [
{
"id": 1,
"name": "hero-rpg",
"description": "Main RPG project",
"storage_path": "/var/lib/uversion/data/hero-rpg",
"is_active": true,
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-05-15T08:30:00Z"
},
{
"id": 2,
"name": "shared-assets",
"description": "Shared asset library",
"storage_path": "/var/lib/uversion/data/shared-assets",
"is_active": true,
"created_at": "2026-02-01T09:00:00Z",
"updated_at": "2026-05-02T11:12:00Z"
}
]
}
返回的字段仅此而已。特别地,既没有 owner,也没有
current_revision,也没有 file_count,也没有 size_bytes,也没有
last_commit_at:仓库在 API 意义上没有所有者,而
容量数据通过管理统计路由获取。
GET /api/repositories/{repo_id}
某仓库的详情。与列表中相同的对象形态,相同的字段。
错误:
403:无权访问此仓库404:仓库不存在
POST /api/repositories
创建一个仓库。仅限超级管理员,即 admin
角色且仅它。这不是一项能力:检查直接落在角色上,因此
project_admin 或 lead 会收到 403。产品中不存在
create_repos 能力。
Body:
{
"name": "new-project",
"description": "可选描述"
}
校验:
name:1 到 255 个字符。服务器不施加任何字符集约束。磁盘上的存储路径由名称经规范化后导出。description:可选。
唯一性落在 仅名称 上,服务器范围内。没有所有者的概念, 因此也没有「按所有者」的唯一性。
错误:
400:空名称或超过 255 个字符403:调用者不具有admin角色409:已有仓库使用此名称
文件
POST /api/files/{repo_id}/upload-chunks
发送 整个一个文件,而非一个碎片列表。该路由名 具有误导性:把文件切成块(chunks)的是 服务器,而不是 你。调用者从来不必自己做这个切分。
由 SHA-256 哈希识别的相同块会被 去重: 服务器上已存在的块不会被第二次存储,无论它来自哪个文件或哪个仓库。 这就是一个仅在边缘改动过的大二进制文件在磁盘空间上几乎不耗费什么的 原因。
Body: 一个只有单个字段的对象,包含以 base64 编码的完整文件。
{
"data": "<整个文件,base64 编码>"
}
任何其他形态,特别是一个 chunks 数组,都会被服务器拒绝。
Response 200:
{
"success": true,
"data": {
"chunks": [
{ "hash": "abc123...", "offset": 0, "size": 1048576, "compressed_size": 423152 },
{ "hash": "def456...", "offset": 1048576, "size": 2097152, "compressed_size": 891204 }
],
"chunks_stored": 1,
"chunks_deduplicated": 1
}
}
chunks 数组应 原样 取用,包括完整对象,用于随后的
POST /commit。chunks_stored 统计实际写入磁盘的块,而
chunks_deduplicated 统计已存在的块。
权限: 要求对仓库有写入,否则 403。
限制: 请求体上限为 1 GB(可通过
security.max_body_size_files 调整)。比此限制更大的文件无法
通过此路由。
POST /api/files/{repo_id}/commit
从一个文件及其块的列表创建一次原子提交。要么所有文件都 通过,要么一个都不通过。
action 仅接受的三个值是 add、modify 和 delete
不是 added,不是 modified,不是 deleted。
这不是形式上的细节。服务器字面地测试
action == "delete" 并把 其余一切 当作一次添加或一次
修改。因此发送 "deleted" 什么都不会删除:文件带着一个空的
chunks 数组进入写入分支,而服务器记录一个
内容为空的修订。不会抛出任何错误。文件依然存在,其最新
版本被空内容覆盖,而这个损失只有在别人下一次 sync 时才会
显现。
Body:
{
"message": "Updated main level + hero pose pass",
"commit_hash": "可选:用于将多个批次归入单个提交",
"files": [
{
"path": "Content/Maps/MainLevel.umap",
"action": "modify",
"chunks": [
{ "hash": "abc123...", "offset": 0, "size": 1048576, "compressed_size": 423152 },
{ "hash": "def456...", "offset": 1048576, "size": 2097152, "compressed_size": 891204 }
]
},
{
"path": "Content/Characters/NewVillain.uasset",
"action": "add",
"chunks": [
{ "hash": "fed789...", "offset": 0, "size": 524288, "compressed_size": 201004 }
]
},
{
"path": "Content/OldAsset.uasset",
"action": "delete",
"chunks": []
}
]
}
chunks 不是可选的,即便在一次 delete 上也是。
该字段必须存在:省略它会使整个请求的反序列化失败。对于
一次删除,请发送一个空数组。
数组里装的是 对象,不是字符串:不加修改地取用
/upload-chunks 返回的 {hash, offset, size, compressed_size} 条目。
每个 hash 必须是 64 个字符的十六进制摘要,否则整个提交会
以 400 被拒绝。
根级别的 commit_hash 字段是可选的。它用于让多次连续
调用由同一个提交来承载,桌面客户端在把一个大上传切成批次时
就是这样做的。省略时,服务器会计算一个。
Response 200:
{
"success": true,
"data": {
"commit_hash": "7f3a9b1c2d3e4f...",
"files_committed": 3,
"revisions": [
{ "path": "Content/Maps/MainLevel.umap", "revision_number": 12 },
{ "path": "Content/Characters/NewVillain.uasset", "revision_number": 1 },
{ "path": "Content/OldAsset.uasset", "revision_number": 8 }
]
}
}
返回的字段仅此而已。既没有 files_changed,也没有
bytes_uploaded,也没有 bytes_deduped,也没有 revision。请注意
revision_number 是一个 按文件 的计数器,而非仓库的
版本号:标识提交的是 commit_hash,而要保存以引用某个
状态的正是它。
错误:
400:畸形的 body、无效的块摘要(期望:64 个十六进制字符)、缺少chunks字段403:对其中某个路径没有写入权限409:某个被修改的文件上有他人持有的锁,或对同一文件的并发提交
GET /api/files/{repo_id}/snapshot
返回仓库在某个给定提交时刻的状态:存在的文件列表, 连同其修订号和大小。桌面客户端用于克隆 和强制同步。
查询参数:
-
commit_hash:必需。它是作为参考点的提交的哈希。 没有此参数,请求会被拒绝,而且不存在「最新状态」这个默认值。
没有 revision 参数。快照按提交哈希请求,
绝不按编号。未知的提交响应 404。
Response 200: 一个扁平数组,没有包裹对象。
{
"success": true,
"data": [
{
"path": "Content/Maps/MainLevel.umap",
"revision_number": 12,
"file_size": 84934656
},
{
"path": "Content/Characters/Hero.uasset",
"revision_number": 3,
"file_size": 5242880
}
]
}
响应中 不 包含块列表。要取回内容,请经由
GET /api/files/{repo_id}/content,它在服务器端重新组装文件。
GET /api/files/{repo_id}/content
下载某个给定修订下文件的内容(服务器重新组装 chunk)。由克隆和 sync 使用。
查询参数:
path:文件路径(相对于 repo 根)revision(可选):修订号。默认:最新。
Response 200: 文件的二进制内容。
GET /api/files/{repo_id}/history
仓库的提交历史,从最新到最旧。
查询参数:
limit:提交数量,默认 50offset:默认 0path(可选):只保留触及此文件的提交
被读取的参数只有这三个。既没有 author 也没有
since:未知的参数会被静默忽略,从而给出一个
貌似合理但未经过滤的响应。请在调用者一侧按作者或按日期过滤。
Response 200: 提交的扁平数组。
{
"success": true,
"data": [
{
"commit_hash": "7f3a9b1c...",
"message": "Fixed lighting in main level",
"author": "alice",
"created_at": "2026-05-15T08:30:00Z",
"files": [
{
"path": "Content/Maps/MainLevel.umap",
"revision_number": 12,
"file_size": 84934656
}
]
}
]
}
既没有 total,也没有 has_more,也不重复 limit 和
offset。要遍历整个历史,请递增 offset 直到收到
少于 limit 的元素。
每个提交都直接携带其触及的文件列表。修订为删除的条目 会被如此标记,且没有可下载的内容。
增量 sync
桌面客户端用于高效同步的端点:
GET /api/files/{repo_id}/sync:自某修订以来更改的文件,用于增量 sync。GET /api/files/{repo_id}/deletions:服务器端删除的文件,用于把删除传播到本地。GET /api/files/{repo_id}/list:repo 的文件列表。POST /api/files/{repo_id}/checkout:获取锁并准备编辑。
锁
锁一直保持,直到被显式释放:通过一次 checkin、一次 revert,或管理员的一次强制解锁。没有任何自动 过期,一小时后没有,一个月后也没有。
expires_at 字段之所以存在,仅因为数据库中对应的列
不接受空值。服务器往里写入一个百年的哨兵值:今天
取得的一把锁会显示一个约在 2126 年的到期。不要在此
字段上构建任何东西,也不要把这个日期显示给用户。
因此 heartbeat 不会延长任何东西。它是一个监控信号,唯一目的是 向管理员显示哪些锁仍在被积极使用。
POST /api/locks/{repo_id}/acquire
对一个路径列表获取锁。这些获取是独立的:acquired
列表包含成功者,failed 列表包含失败者及其原因。
Body:
{
"paths": [
"Content/Maps/MainLevel.umap",
"Content/Characters/Hero.uasset"
]
}
Response 200:
{
"success": true,
"data": {
"acquired": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"file_id": 12345,
"file_path": "Content/Maps/MainLevel.umap",
"user_id": 12,
"username": "alice",
"acquired_at": "2026-05-15T14:30:00Z",
"expires_at": "2126-05-15T14:30:00Z"
}
],
"failed": [
{
"path": "Content/Characters/Hero.uasset",
"reason": "File is locked",
"locked_by": "bob"
}
]
}
}
此例中 2126 年的到期不是笔误:它是上面描述的哨兵值。锁是 永久的。
失败原因。 reason 字段是一个 用于显示的
英文句子,而非一个稳定的代码。不要编写比较这个字符串的逻辑,
也不要「解析」它的前缀:它可能在不同版本间被改写。
当前产生的值是:
reason | 含义 | locked_by |
|---|---|---|
File is locked | 已有他人持有该锁 | 那个人的名字 |
No write permission | 此路径上没有写入权 | null |
Failed to create lock | 在数据库中设置锁失败 | null |
Database error | 此路径上的数据库错误 | null |
一个无效路径(绝对路径、含 ..、为空、超过 4096 个字符或带有
空字节)不会在 failed 中产生一个条目:它会使整个请求失败。
POST /api/locks/{repo_id}/release
释放你持有的锁。Body:
{
"paths": ["Content/Maps/MainLevel.umap"]
}
此路由上没有 force 字段。 加一个不会有任何
效果:服务器会忽略它不认识的字段,请求会成功,而他人的锁
会原地保留。指望它的集成者以为自己释放了文件,其实并没有。
要移除他人的锁,唯一的路由是
POST /api/admin/locks/{lock_id}/force-release。它保留给相关仓库的管理,
且该操作会写入审计日志。它接收锁的标识符,
你通过 GET /api/locks/{repo_id}/status 获得它。
POST /api/locks/{repo_id}/heartbeat
更新你的锁的活动时间戳。这不会延长任何东西,因为 没有任何东西会过期:它是一个监控信号,让管理员能区分一把 仍在使用的锁和一把被遗忘的锁。
Body: 一个路径的 JSON 数组,直接给出,没有包裹对象。
["Content/Maps/MainLevel.umap", "Content/Characters/Hero.uasset"]
无效路径会被逐个忽略,而不是使整个批次失败,以免一个陈旧的 追踪残留物阻塞其余。
GET /api/locks/{repo_id}/status
列出仓库的所有锁,连同文件名和持有它的人的名字。
查询参数:无。 既没有 user 过滤器,也没有 limit,
也没有 offset。该路由返回仓库锁的全部,供在调用者一侧过滤。
Response 200: 一个扁平数组,没有包裹对象也没有 total。
{
"success": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"file_id": 12345,
"file_path": "Content/Maps/MainLevel.umap",
"user_id": 12,
"username": "alice",
"acquired_at": "2026-05-15T14:30:00Z",
"expires_at": "2126-05-15T14:30:00Z"
}
]
}
id 字段就是要传给
POST /api/admin/locks/{lock_id}/force-release 的那个。
评论 & 审阅
GET /api/comments/{repo_id}/comments?commit_hash=<hash>
列出一次提交的评论,按线程(父 → 子)。
Response 200:
{
"success": true,
"data": [
{
"id": 42,
"commit_hash": "7f3a9b1c...",
"file_path": "Content/Maps/MainLevel.umap",
"author_id": 12,
"author_username": "alice",
"body": "LGTM, ship it",
"parent_id": null,
"created_at": "2026-05-15T15:00:00Z",
"replies": [
{
"id": 43,
"author_username": "bob",
"body": "Thanks!",
"parent_id": 42,
"created_at": "2026-05-15T15:05:00Z"
}
]
}
]
}
POST /api/comments/{repo_id}/comments
创建一条评论。file_path 是可选的(提交评论 vs. 文件评论)。parent_id 用于回复一个已有的线程。
Body:
{
"commit_hash": "7f3a9b1c...",
"file_path": "Content/Maps/MainLevel.umap",
"body": "LGTM, ship it",
"parent_id": null
}
POST /api/comments/{repo_id}/reviews
对一次提交提交一份审阅。需要 approve_changes 能力。
Body:
{
"commit_hash": "7f3a9b1c...",
"status": "approved",
"comment": "Lighting looks great, approving"
}
接受的 status:approved、changes_requested、pending。
关注列表
GET /api/watchlist/{repo_id}/watchlist
列出当前用户在此仓库上的关注模式。
Response 200:
{
"success": true,
"data": [
{
"id": 1,
"user_id": 12,
"pattern": "Content/Characters/Hero/**",
"notify_on": ["commit", "lock"],
"created_at": "2026-04-10T10:00:00Z"
}
]
}
POST /api/watchlist/{repo_id}/watchlist
添加一个关注模式。Body:
{
"pattern": "Content/Characters/Hero/**",
"notify_on": ["commit", "lock"]
}
可能的事件:commit(一次提交修改了一个匹配的文件)、
lock(获取了一把锁)、review(发布了一份审阅)。
DELETE /api/watchlist/{repo_id}/watchlist/{watch_id}
移除一个关注模式。返回 { "success": true }。
生产看板(任务)
旧的 modification-requests API 已被移除:生产看板吸收了它
(请求变成卡片,origin='request')。端点嵌套于
/api/tasks/{repo_id} 之下。
| 路由 | 描述 |
|---|---|
GET /api/tasks/{repo_id}/board | 完整看板:列 + 卡片 |
POST /api/tasks/{repo_id}/tasks | 创建一张卡片 |
PUT/DELETE /api/tasks/{repo_id}/tasks/{task_id} | 更新 / 删除一张卡片 |
PUT /api/tasks/{repo_id}/tasks/{task_id}/assignees | 为一张卡片分配用户 |
GET /api/tasks/{repo_id}/assignable-users | 可分配用户的列表 |
POST /api/tasks/{repo_id}/columns | 配置看板的列 |
同样可用的子路由:卡片评论、指向资产和提交的链接,以及附件
(带封面图)。列的配置需要 manage_board 能力(admin / lead)。
构建
POST /api/builds/{repo_id}/upload?hash=<sha256>
把一个构建文件上传到 <storage_path>/builds/<hash>。
服务器将查询参数中给出的 SHA-256 与收到的内容进行校验。如果该哈希已在
服务器端存在,立即返回 200 而不再复制(构建存储端的去重)。
标头: Content-Type: application/octet-stream。
Body: 原始二进制(无 JSON 包装)。
限制: 每个文件 8 GB。
错误:
400:hash 查询参数缺失或畸形,或计算出的 SHA-256 != 提供的 hash413:文件 > 8 GB
POST /api/builds/{repo_id}/publish
在上传完一次构建的所有文件后,登记它的 manifest。需要 publish_builds 能力。
Body:
{
"version": "0.1.5-nightly",
"config": "Development",
"platform": "Win64",
"executable_path": "HeroRPG/Binaries/Win64/HeroRPG.exe",
"release_notes": "Nightly build of main branch, 2026-05-15",
"files": [
{ "path": "HeroRPG.exe", "hash": "abc123...", "size_bytes": 12345678 },
{ "path": "HeroRPG/Content/Paks/pak0.pak", "hash": "def456...", "size_bytes": 2147483648 }
]
}
GET /api/builds/{repo_id}
列出此项目已发布的构建。
对构建的访问是逐项目授予的。 download_builds
能力并不够:由于它是服务器范围的,它只说明该账户不是一个单纯的
旁观者。此外还需要,要么对仓库的访问,要么对此项目
构建的一份显式授权,由其管理员通过
/api/admin/repositories/{repo_id}/build-access 授予。项目管理员当然可以
访问他所管理的那些,超级管理员访问全部。
Response 200:
{
"success": true,
"data": [
{
"id": 5,
"version": "0.1.5-nightly",
"config": "Development",
"platform": "Win64",
"published_by": "ci-nightly",
"published_at": "2026-05-15T03:00:00Z",
"size_bytes": 2159829326,
"file_count": 142,
"release_notes": "Nightly build of main branch, 2026-05-15"
}
]
}
GET /api/builds/{repo_id}/{build_id}/file
下载一次构建的一个文件。需要 download_builds 能力。一次构建的 manifest 可通过 GET /api/builds/{repo_id}/{build_id}/manifest 获得。
管理
真正守护这些路由的是什么
与人们可能预期的相反,/api/admin/* 路由并不是
每个都由一项能力守护。它们通过 四种检查 之一,而这些几乎都
落在 角色 上。一项 capability 是附加在某角色上的一个具名权利;
要点在于它
在整个服务器上有效,因为它不携带对任何仓库的引用。因此
它绝不能用来把某人限定在一个项目上。
| 检查 | 对谁通过 | 用于什么 |
|---|---|---|
| 超级管理员 | admin 角色,且仅它 |
一切适用于整个服务器的:账户、组、审计日志、全局统计、许可证、服务器更新。 |
| 此 仓库的管理员 | admin,或管理此特定仓库的 project_admin |
一切属于某个项目的:权限、校验规则、Webhook、构建访问、强制解锁、垃圾回收。 |
| 服务器范围的能力 | 任何持有该具名能力的角色 | 少数横切路由。注意:能力在 所有 仓库上都有效,这是它的本性。一个 project_admin 一项都不持有,改为仅在他所管理的仓库上被允许。 |
| 仅准入 | admin 或 project_admin |
放行,但 不授权任何东西。使用它的路由随后必须自己把结果限制在调用者所管理的仓库上。 |
换言之:manage_users 和 manage_permissions
能力只存在于纸面上。它们确实在安装时被创建于数据库中,
但没有任何一行代码查询它们。把它们授予某角色不会改变任何东西。
不要编写这样的集成:假定一个非 admin 账户因为被给了
manage_users 就能管理用户:它会收到一个 403。
| 路由 | 实际检查 | 描述 |
|---|---|---|
GET /api/admin/users | 仅准入,然后过滤 | 一个 project_admin 只看到其范围内的账户 |
POST /api/admin/users | 仅准入 | 创建一个账户 |
PUT/DELETE /api/admin/users/{id} | 超级管理员 | 更新或删除一个账户 |
POST /api/admin/users/{id}/reset-password | 超级管理员 | 重置密码并吊销该账户的所有令牌 |
GET/POST /api/admin/groups | 超级管理员 | 组是全局的,不能按项目委派 |
GET/POST /api/admin/permissions/{repo_id} | 此仓库的管理员 | 按路径模式的权限规则,授予某个组或某个用户 |
GET/POST /api/admin/repositories/{repo_id}/rules | 此仓库的管理员 | 在发送前应用的校验规则 |
GET/POST /api/admin/repositories/{repo_id}/webhooks | 此仓库的管理员 | Discord、Slack、Teams,或通用 Webhook |
GET /api/admin/locks | 仅准入,然后过滤 | 锁,限于所管理的仓库 |
POST /api/admin/locks/{lock_id}/force-release | 此仓库的管理员 | 移除他人的锁。写入审计日志 |
GET /api/admin/audit | 超级管理员 | 审计日志,可过滤并分页 |
GET /api/admin/stats | 超级管理员 | 存储容量、去重率、增长 |
GET /api/admin/licence | 超级管理员 | 许可证状态与已消耗的席位 |
GET /api/admin/repositories/{repo_id}/gc/preview | 此仓库的管理员 | 模拟垃圾回收而不删除任何东西 |
POST /api/admin/repositories/{repo_id}/gc | 此仓库的管理员 | 启动垃圾回收 |
角色、它们的等级以及各自能做什么的完整细节,见专门讲述角色与权限的 页面。
本页未涵盖的 API 部分
以下路由族存在,被服务器挂载并提供,但此处并未 描述。如果你的集成需要它们,今天最可靠的做法是观察桌面 客户端所发出的调用,或写信给我们。
| 前缀 | 它涵盖什么 |
|---|---|
/api/advisor/* | Project Health:Unreal 项目审计、其发现、按支柱的评分,以及要忽略项目的分拣。 |
/api/distribution/* | 项目间分发:源仓库与目标仓库之间的链接、把文件从一个项目发布到另一个项目、历史。 |
/api/binaries/* | 与一次提交绑定、按 UnrealGameSync 模型的预编译编辑器二进制。 |
/api/watchlist/* | 超出上面描述的关注模式之外:通知与收件箱。 |
/api/profile/* | 当前用户的档案。 |
/api/admin/repositories/{repo_id}/admins | 谁管理一个仓库:项目管理员的指派与撤除。 |
/api/admin/repositories/{repo_id}/build-access | 谁能下载此项目的构建,逐项目授予。 |
/api/admin/licence | 许可证状态与已消耗的席位。 |
/api/admin/server/* | 从管理面板进行的服务器更新。 |
健康检查
GET /health
无需认证的 端点,如果服务器能访问数据库则返回 200 OK。
用于负载均衡器或监控的健康检查(Prometheus blackbox、Datadog synthetic 等)。
Response 200: OK(text/plain)。
Response 503: 如果数据库不可达。
GET /api/server-info
返回服务器元数据(版本等)的 公开、无需认证的 端点。
与 /health 一样,它不受认证中间件保护。
错误格式
出错时,一个已认证路由响应 { "success": false, "error": "..." },
而一个 /api/auth/* 路由响应 { "error": "..." },两种情况下
都带一个合适的 HTTP 代码。
error 字段是一条 面向人的消息,而非一个代码。
不存在任何稳定错误代码的目录,无论在正文中还是在某个标头中。因此不要
在其内容上构建逻辑,也不要分析它的前缀:这些句子会在不同版本间被
改写,而一个静默损坏的字符串测试比完全没有测试更糟。
唯一可以据以分支行为的东西是 HTTP 代码。 下表给出 它的读法。
| HTTP | 含义 | 何时 |
|---|---|---|
| 400 | Bad request | 无效参数、畸形 JSON、正文过大(在硬性 413 限制之前) |
| 401 | Not authenticated | 令牌缺失、过期、签名无效,或 token_version 不匹配 |
| 403 | Permission denied | 缺少能力,或对 path / repo 没有权限 |
| 404 | Resource not found | repo / 文件 / commit / user 不存在或不可访问 |
| 409 | Conflict | 锁已被持有、并发 commit、违反 unique 约束 |
| 413 | Payload too large | 正文超过限制(/files 上 1 GB,/builds/upload 上 8 GB) |
| 429 | Too many requests | 仅在 /auth/login 上(按用户的速率限制) |
| 500 | Internal server error | DB 错误、IO 错误。在服务器端记录,报告 bug 时请附上 timestamp。 |
| 503 | Service unavailable | 数据库不可达(health check)或迁移进行中 |