Halo 2.27.1 REST API 与个人访问令牌详解
Halo 2.27.1 REST API 实测指南:Console API 与 Content API 的鉴权差异、个人访问令牌(PAT)创建与吊销、文章/分类/标签 CRUD、附件 multipart 上传、发布与取消发布,附可直接复用的 curl 命令与真实响应。
Halo 2.27.1 REST API 与个人访问令牌详解
目标:用最短路径把 Halo 2.27.1 的接口跑通。全文命令与响应均在 2026-10-27 的测试站点(
http://127.0.0.1:8090,管理员root/q1w2e3r4)真实执行后摘录,返回码、字段名、错误文案均未改写。
一、环境信息
- 操作系统:Ubuntu 22.04.5 LTS(x86_64)
- Halo:2.27.1(JAR + systemd 运行)
- JDK:Eclipse Temurin 21.0.13+9 LTS
- 数据库:PostgreSQL 14.24
- 站点地址:
http://127.0.0.1:8090 - 管理员:
root/q1w2e3r4 - 实测工具:
curl 8.x、Python 3.14(仅用于登录时的 RSA 加密)
二、接口体系与鉴权
Halo 2.x 的 HTTP 接口分三层,鉴权要求各不相同:
| 接口层 | 路径前缀 | 鉴权要求 | 用途 |
|---|---|---|---|
| Content API | /apis/api.content.halo.run/v1alpha1/ | 匿名可读 | 主题渲染、公开内容读取 |
| Console API | /apis/api.console.halo.run/v1alpha1/ | 必须登录 | 后台管理、文章编辑 |
| UC API | /apis/uc.api.*.halo.run/v1alpha1/ | 必须登录 | 用户中心(资料、令牌) |
| Extension API | /apis/{group}/{version}/{plural} | 按资源策略 | 通用资源增删改查 |
实测三种访问状态:
$ curl -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts
302 # 未登录,被重定向到登录页
$ curl -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8090/apis/api.content.halo.run/v1alpha1/posts
200 # 公开内容接口,匿名可读
$ curl -H "Authorization: Bearer pat_xxx" .../apis/api.console.halo.run/v1alpha1/posts
200 # 携带有效令牌
鉴权方式有两种:
- 会话 Cookie:浏览器登录后自动携带
SESSION、XSRF-TOKEN、device_id,适合控制台页面。 - 个人访问令牌(PAT):请求头
Authorization: Bearer pat_...,适合脚本、CI、第三方集成。
注意:登录表单提交的密码不是明文,页面用 RSA 公钥加密后再提交;因此不要试图用 curl -d "password=q1w2e3r4" 直接登录,会得到 302 /login?error=invalid-credential&method=local。脚本化请改用 PAT。
三、个人访问令牌(PAT)
3.1 令牌对象结构
{
"apiVersion": "security.halo.run/v1alpha1",
"kind": "PersonalAccessToken",
"metadata": { "generateName": "pat-" },
"spec": {
"name": "",
"description": "D5 实测-超管令牌",
"expiresAt": null,
"roles": ["super-role"]
}
}
spec.roles决定令牌权限。不填 roles 的令牌权限为空,任何 Console API 都会返回 403。expiresAt为 ISO 8601 时间,null表示永不过期。metadata.generateName由服务端生成metadata.name(实测pat-m0giecrg)。
3.2 创建令牌
用户中心的创建接口会自动补全 tokenId、username:
curl -X POST http://127.0.0.1:8090/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"apiVersion":"security.halo.run/v1alpha1","kind":"PersonalAccessToken",
"metadata":{"generateName":"pat-"},
"spec":{"name":"","description":"D5 实测-超管令牌","expiresAt":null,"roles":["super-role"]}}'
实测响应(HTTP 200):
{"apiVersion":"security.halo.run/v1alpha1","kind":"PersonalAccessToken",
"metadata":{"annotations":{"security.halo.run/access-token":"pat_cGF0LW0wZ2llY3JnOjJxU0FsZG9mQ1F0MHI4QmpTclRZVEU0bG9nUV9qUlRk"},
"creationTimestamp":"2026-10-27T13:49:36.959918119Z","generateName":"pat-","name":"pat-m0giecrg","version":0},
"spec":{"description":"D5 实测-超管令牌","name":"","revoked":false,"roles":["super-role"],
"tokenId":"87256764ae221dc97a2affab022cf45a7e84e82ad03dca0acdfd7e0e02cd15e9","username":"root"}}
令牌明文只在创建响应里出现一次,位于 metadata.annotations["security.halo.run/access-token"],必须当场保存;之后列表接口只返回 tokenId,不会返回明文。
实测差异:直接调用资源接口 /apis/security.halo.run/v1alpha1/personalaccesstokens 创建会失败,因为缺 tokenId/username:
{"detail":"Validation error(s) :\nspec: Field 'tokenId' is required. (code: 1026)\nspec: Field 'username' is required. (code: 1026)\n of schema security.halo.run/v1alpha1/PersonalAccessToken.",
"status":400,"title":"Schema Violation"}
结论:脚本创建令牌走 UC 接口,资源接口仅用于查询、修改、删除。
3.3 使用令牌
TOKEN='pat_cGF0LW0wZ2llY3JnOjJxU0FsZG9mQ1F0MHI4QmpTclRZVEU0bG9nUV9qUlRk'
curl -s -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/users/root
返回 200,spec 中包含 username、displayName,并在 roles 数组里给出该用户拥有的角色,可用来确认令牌归属:
{"user":{"kind":"User","metadata":{"name":"root",
"annotations":{"rbac.authorization.halo.run/role-names":"[\"super-role\"]"}},
"spec":{"displayName":"Administrator","email":"root@quzuo.net","disabled":false}},
"roles":[{"kind":"Role","metadata":{...}}]}
若令牌没有 roles,同一请求返回 403,响应头明确说明权限不足:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
error_description="The request requires higher privileges than provided by the access token."
3.4 查询与吊销
# 列表
curl -s -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8090/apis/security.halo.run/v1alpha1/personalaccesstokens?page=0&size=20"
# 吊销(JSON Patch,媒体类型必须是 application/json-patch+json)
curl -X PATCH \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json-patch+json" \
-d '[{"op":"replace","path":"/spec/revoked","value":true}]' \
http://127.0.0.1:8090/apis/security.halo.run/v1alpha1/personalaccesstokens/pat-m0giecrg
吊销后立即生效,用该令牌再访问 Console API:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Token has been revoked."
恢复只需把 value 改为 false,实测恢复后同一令牌重新返回 200。
实测坑点:PATCH 的 Content-Type 若写成 application/json,会直接 415:
{"detail":"Content type application/json is not supported. Supported media types: [application/json-patch+json].",
"status":415,"title":"Unsupported Media Type"}
spec.lastUsed 会在每次调用后自动更新(实测为最后一次请求时间),可用于审计闲置令牌。
四、文章接口
4.1 列表
curl -s -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts?page=0&size=1"
返回分页结构,page 从 1 开始计数:
{"page":1,"size":1,"total":1,
"items":[{"post":{"kind":"Post","metadata":{"name":"5152aea5-...","labels":{"content.halo.run/published":"true"}},
"spec":{"title":"Hello Halo","slug":"hello-halo","publish":true,"visible":"PUBLIC"}},
"owner":{"displayName":"Administrator","name":"root"},
"categories":[...],"tags":[...],"contributors":[...],"stats":{...}}]}
注意 Console API 的列表项是"包装对象":文章本体在 items[].post,不是直接平铺。
支持的查询参数(实测):
| 参数 | 示例 | 说明 |
|---|---|---|
page | 0 | 页码,响应中归一为 1 起 |
size | 10 | 每页条数 |
keyword | Hello | 标题/摘要关键词 |
publish | true | 仅已发布 |
实测坑点:Console API 没有 GET posts/{name},返回 404:
{"detail":"No static resource apis/api.console.halo.run/v1alpha1/posts/5152aea5-...","status":404,"title":"Not Found"}
单篇详情请用 Content API:
curl -s http://127.0.0.1:8090/apis/api.content.halo.run/v1alpha1/posts/5152aea5-c2e8-4717-8bba-2263d46e19d5
4.2 创建草稿
curl -X POST http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"post": {
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {"name": "d5-post", "labels": {"content.halo.run/draft": "true"}},
"spec": {
"title": "D5 API 实测文章", "slug": "d5-api-post", "template": "", "cover": "",
"deleted": false, "publish": false, "pinned": false, "allowComment": true,
"visible": "PUBLIC", "priority": 0,
"excerpt": {"autoGenerate": true, "raw": ""},
"categories": ["d5-cat"], "tags": ["d5-tag"], "htmlMetas": []
}
},
"content": {
"raw": "# D5 API 实测\n\n这是通过 REST API 创建的文章。\n",
"content": "<h1>D5 API 实测</h1><p>这是通过 REST API 创建的文章。</p>",
"rawType": "MARKDOWN"
}
}'
请求体是 PostRequest,由两部分组成:post(元数据与 spec)和 content(raw 原文、content 渲染后的 HTML、rawType)。实测响应 200,关键字段:
{"kind":"Post","metadata":{"name":"d5-post","version":1,
"labels":{"content.halo.run/draft":"true"}},
"spec":{"title":"D5 API 实测文章","slug":"d5-api-post","owner":"root",
"baseSnapshot":"d59979a3-2f8a-4d76-a26f-af0d9d1c2f02",
"headSnapshot":"d59979a3-2f8a-4d76-a26f-af0d9d1c2f02",
"categories":["d5-cat"],"tags":["d5-tag"],"publish":false},
"status":{"phase":"DRAFT","conditions":[{"type":"DRAFT","status":"TRUE",
"reason":"DraftedSuccessfully","message":"Drafted post successfully."}]}}
服务端自动补了 spec.owner(当前令牌用户)、baseSnapshot/headSnapshot(内容快照名),并写入草稿标签。metadata.name 需自行指定或改用 generateName。
4.3 更新正文
curl -X PUT http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts/d5-post/content \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"raw":"# D5 API 实测(更新)\n\n第二版正文。\n",
"content":"<h1>D5 API 实测(更新)</h1><p>第二版正文。</p>","rawType":"MARKDOWN"}'
实测 200,metadata.version 递增(由 1 到 5),并新增内容校验注解 checksum/content。若只想改标题等元数据,用 PUT posts/{name} 提交同样的 PostRequest 结构;rawType 支持 MARKDOWN 与 HTML。
4.4 内容快照
curl -s -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts/d5-post/snapshot
curl -s -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts/d5-post/head-content
curl -s -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts/d5-post/release-content
snapshot 返回快照数组;head-content 是当前编辑内容;release-content 是已发布版本。实测二者在发布前均返回同一份 HTML 内容。
五、分类与标签
分类、标签走 Extension API,接口形态一致。
创建分类:
curl -X POST http://127.0.0.1:8090/apis/content.halo.run/v1alpha1/categories \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"apiVersion":"content.halo.run/v1alpha1","kind":"Category",
"metadata":{"name":"d5-cat"},
"spec":{"displayName":"D5 实测分类","slug":"d5-cat","description":"D5 文档实测分类",
"cover":"","template":"","priority":0,"children":[]}}'
实测 HTTP 201:
{"spec":{"displayName":"D5 实测分类","slug":"d5-cat","description":"D5 文档实测分类",
"cover":"","template":"","priority":0,"children":[],
"preventParentPostCascadeQuery":false,"hideFromList":false},
"kind":"Category","metadata":{"name":"d5-cat","creationTimestamp":"2026-10-27T13:49:58.009064660Z","version":0}}
创建标签:
curl -X POST http://127.0.0.1:8090/apis/content.halo.run/v1alpha1/tags \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"apiVersion":"content.halo.run/v1alpha1","kind":"Tag",
"metadata":{"name":"d5-tag"},
"spec":{"displayName":"D5 实测标签","slug":"d5-tag","color":"#3b82f6",
"cover":"","description":"D5 文档实测标签"}}'
实测 HTTP 201,返回 {"spec":{"displayName":"D5 实测标签","slug":"d5-tag","color":"#3b82f6",...}}。
实测坑点:显示名必须放在 spec.displayName。如果把 displayName 写进 metadata,服务端不会报错,但会被直接丢弃,创建出的分类/标签没有名称。
分类树由 Console API 单独提供:
curl -s -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/categories/-/tree
调整分类层级用 PUT /apis/api.console.halo.run/v1alpha1/categories/{name}/position,请求体为 CategoryPositionRequest。
删除资源:
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8090/apis/content.halo.run/v1alpha1/tags/d5-tag
实测返回 200,返回体里带有 deletionTimestamp,但对象仍在:
{"kind":"Tag","metadata":{"name":"d5-tag","deletionTimestamp":"2026-10-27T13:52:41.588532157Z",
"finalizers":["tag-protection"],"version":7},"status":{"permalink":"/tags/d5-tag"}}
这是 finalizer 机制:DELETE 只是打删除标记,后台清理完成前对象仍可读到。约 2 秒后再查同一地址,实测已返回 404,删除真正完成。
六、附件上传
上传使用 multipart/form-data,端点为 /apis/api.console.halo.run/v1alpha1/attachments/upload:
curl -X POST http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/attachments/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/tmp/d5-upload.png" \
-F "policyName=default-policy"
policyName 对应存储策略的 metadata.name,默认策略为 default-policy(本地存储)。实测成功响应 200:
{"apiVersion":"storage.halo.run/v1alpha1","kind":"Attachment",
"metadata":{"name":"f30cb7fe-f0b9-4dd3-84c9-752d3253de48",
"annotations":{"storage.halo.run/uri":"/upload/d5-upload.png",
"storage.halo.run/local-relative-path":"upload/d5-upload.png"}},
"spec":{"displayName":"d5-upload.png","mediaType":"image/png","ownerName":"root",
"policyName":"default-policy","size":70},
"status":{"permalink":"/upload/d5-upload.png",
"thumbnails":{"S":"/upload/d5-upload.png?width=400","M":"...width=800",
"L":"...width=1200","XL":"...width=1600"}}}
实测坑点:默认策略有类型白名单,上传 text/plain 直接 415:
{"detail":"不支持上传 text/plain 类型的文件。","status":415,"title":"文件类型不允许"}
附件本身是 storage.halo.run/v1alpha1/Attachment 资源,可通过 Extension API 查询与删除:
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8090/apis/storage.halo.run/v1alpha1/attachments
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8090/apis/storage.halo.run/v1alpha1/attachments/f30cb7fe-f0b9-4dd3-84c9-752d3253de48
七、发布与取消发布
发布:
curl -X PUT -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts/d5-post/publish
实测 200,spec.publish 变为 true,并写入已发布快照:
{"spec":{"publish":true,"releaseSnapshot":"d59979a3-2f8a-4d76-a26f-af0d9d1c2f02"},
"metadata":{"annotations":{"content.halo.run/last-released-snapshot":"d59979a3-2f8a-4d76-a26f-af0d9d1c2f02"},
"labels":{"content.halo.run/published":"true"}},
"status":{"conditions":[{"type":"PUBLISHED","status":"TRUE","message":"Post published successfully."}]}}
发布后站点页面立即可访问:
$ curl -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8090/archives/d5-api-post
200
$ curl -s http://127.0.0.1:8090/archives/d5-api-post | grep -o "<title>[^<]*</title>"
<title>D5 API 实测文章 - Halo 技术文档站</title>
取消发布:
curl -X PUT -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts/d5-post/unpublish
实测 200,spec.publish 为 false;随后 Content API 立即 404:
$ curl -o /dev/null -w "%{http_code}\n" .../apis/api.content.halo.run/v1alpha1/posts/d5-post
404
实测现象:取消发布后第一次访问文章页面仍返回 200(主题侧缓存尚未失效),约 1 秒后再次访问变为 404。写自动化断言时需要留出这段缓存窗口,或者以 Content API 的 404 作为唯一判断依据。
回收站:PUT posts/{name}/recycle 会把 spec.deleted 置为 true,文章进入回收站而不是物理删除;彻底删除仍走 DELETE /apis/content.halo.run/v1alpha1/posts/{name}。
八、分页、筛选与通用约定
- 请求参数统一为
page、size;响应结构为{"page":1,"size":N,"total":N,"items":[...],"totalPages":N,"hasNext":...,"hasPrevious":...,"first":...,"last":...}。 - 请求
page=0与page=1等价,响应中的page始终从 1 开始。 - 扩展资源列表默认
size=1000(如令牌列表),管理端列表需显式传size。 - Extension API 的创建成功码是 201,Console API 的动作接口(发布、取消发布、更新)多为 200。
- 所有时间字段为 ISO 8601 UTC(
2026-10-27T13:49:36.959918119Z),本地展示需自行转换时区。
九、错误码速查
| 状态码 | 典型文案 | 原因 |
|---|---|---|
| 302 | 重定向至 /login | 未携带会话或令牌访问 Console API |
| 400 | Schema Violation / spec: Field 'tokenId' is required | 请求体缺必填字段 |
| 401 | invalid_token / Token has been revoked. | 令牌已吊销或格式错误 |
| 403 | insufficient_scope | 令牌未分配角色或权限不足 |
| 404 | No static resource ... | 接口不存在,或资源已删除 |
| 415 | Content type application/json is not supported | PATCH 未用 application/json-patch+json |
| 415 | 不支持上传 text/plain 类型的文件。 | 附件类型不在存储策略白名单 |
排查顺序建议:先看 302/401 区分"没带凭证"还是"凭证失效",再看 403 判断角色,最后看 404 是否路径拼错(尤其 Console API 与 Content API 混用)。
十、参考链接
- Halo 官方文档:https://docs.halo.run
- Halo 2.27.1 发布页:https://github.com/halo-dev/halo/releases/tag/v2.27.1
- Halo 接口源码(Console):https://github.com/halo-dev/halo/tree/v2.27.1/application/src/main/java/run/halo/app/core/endpoint/console
- Halo 接口源码(Content):https://github.com/halo-dev/halo/tree/v2.27.1/application/src/main/java/run/halo/app/core/endpoint/content
- OpenAPI 规范:https://spec.openapis.org/oas/latest.html