Halo 2.27.1 REST API 与个人访问令牌详解

Halo 2.27.1 REST API 实测指南:Console API 与 Content API 的鉴权差异、个人访问令牌(PAT)创建与吊销、文章/分类/标签 CRUD、附件 multipart 上传、发布与取消发布,附可直接复用的 curl 命令与真实响应。

HaloHaloCMS建站JavaSpring Boot2026/10/27

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                      # 携带有效令牌

鉴权方式有两种:

  1. 会话 Cookie:浏览器登录后自动携带 SESSION、XSRF-TOKEN、device_id,适合控制台页面。
  2. 个人访问令牌(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,不是直接平铺。

支持的查询参数(实测):

参数示例说明
page0页码,响应中归一为 1 起
size10每页条数
keywordHello标题/摘要关键词
publishtrue仅已发布

实测坑点: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
400Schema Violation / spec: Field 'tokenId' is required请求体缺必填字段
401invalid_token / Token has been revoked.令牌已吊销或格式错误
403insufficient_scope令牌未分配角色或权限不足
404No static resource ...接口不存在,或资源已删除
415Content type application/json is not supportedPATCH 未用 application/json-patch+json
415不支持上传 text/plain 类型的文件。附件类型不在存储策略白名单

排查顺序建议:先看 302/401 区分"没带凭证"还是"凭证失效",再看 403 判断角色,最后看 404 是否路径拼错(尤其 Console API 与 Content API 混用)。

十、参考链接