面向其他 Halo 插件开发者。interaction-plus 通过 Halo 扩展点向其他插件开放进程内能力:
| API | 接口 | 方向 | 用途 |
|---|---|---|---|
| 发奖 API | DecorationGrantApi |
写 | 在用户达成你定义的条件时发放 / 撤销装饰(勋章、头像框、称号、名片背景、昵称样式) |
| 身份查询 API | PublicIdentityQueryApi |
读 | 在你的插件后端查询用户公开身份与装扮,内嵌进你自己的接口响应 |
| 统计贡献 | UserStatContributor |
反向 | 把你领域内的用户统计项贡献到用户卡数据行与公开身份 |
前两个 API 的接入方式完全相同(见快速开始),可按需只用其一。统计贡献是反向扩展点,由本插件调用你的实现。
对外 API 版本为
1.0.0,以下内容是该版本的当前契约。以下能力仅在
interaction-plus已安装并启用时可用,请做好降级。
如果你的插件提供由 Halo 渲染的 Thymeleaf 前台页面,并在页面中输出 hip-*,可以直接使用 Interaction Plus 注册到模板引擎的 Finder;不需要额外的版本接口或重定向:
<th:block th:if="${pluginFinder.available('interaction-plus')}">
<script th:src="${interactionPlus.getRuntimeUrl()}"></script>
</th:block>getRuntimeUrl() 返回带当前安装版本的完整 Runtime 地址,使固定文件名对应当前版本的浏览器缓存。若你的插件已声明 interaction-plus 为必需依赖,可以省略外层可用性判断。Finder 属于模板能力;插件 Java 后端要调用数据或发奖,仍按下文使用 ExtensionGetter。
共 3 步。
api 模块通过 JitPack 提供。先添加仓库(推荐放在 settings.gradle 的 dependencyResolutionManagement,或根 build.gradle;JitPack 建议排在其他仓库之后):
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}再声明依赖(compileOnly——运行时由 interaction-plus 提供):
dependencies {
compileOnly 'com.github.Tim0x0:halo-plugin-interaction-plus:1.0.0'
}注意坐标是仓库级扁平坐标(group=GitHub 用户、artifact=仓库名),没有
:api:段: 本项目用自定义install命令构建,JitPack 会把产物归一到这个坐标。api模块只包含接口与 DTO;运行时实现由 Interaction Plus1.0.0提供。
在你的 plugin.yaml 中:
spec:
pluginDependencies:
"interaction-plus?": ">=1.0.0" # 末尾问号 = 可选依赖可选依赖表示 interaction-plus 没装 / 没启用时,你的插件照常启动,只是对应能力不可用。
各插件的 Spring 容器相互隔离,不能直接 @Autowired 这些接口。统一经 Halo 核心的 ExtensionGetter(自 Halo 2.18 起对插件开放)取用:
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import com.timxs.interactionplus.api.DecorationGrantApi;
private final ExtensionGetter extensionGetter;
private Mono<DecorationGrantApi> grantApi() {
return extensionGetter.getEnabledExtension(DecorationGrantApi.class);
}身份查询 API 同款,把接口类换成 PublicIdentityQueryApi 即可。
用
getEnabledExtension(不是getExtensions)。interaction-plus缺席时它返回空Mono,下游自然跳过。
分工:你的插件判断业务条件(签到 7 天、积分达标、活动参与……),
interaction-plus只负责把装饰发下去 / 撤回来。条件判断完全在你这边,本插件不感知、不监听任何条件。
- 你写了签到 / 积分 / 等级 / 活动等插件,想在用户达成条件时发一个装饰作为奖励。
- 你只管「何时发、何时撤」;装饰本身(图、名称、稀有度)由站长在
interaction-plus后台管理,你不负责"造装饰"。
两类发放场景:
| 场景 | 怎么调 | 例子 |
|---|---|---|
| 永久型(达成即发、不收回) | 只调 grant,永不 revoke |
连续签到 7 天发个永久勋章 |
| 持续型(满足才有、中断即撤) | 达成 grant、中断 revoke、再达成再 grant |
连续留言期间挂个标,断了就摘 |
- 多来源并存(谁发的谁管):授予按来源相互独立——你、其他插件、站长后台可以各自给同一用户发同一装饰,互不吞并、互不覆盖;用户持有 = 任一来源存在有效授予,展示有效期取各来源中最晚。你只需管好自己的发与撤,无需关心别人是否发放过。
- 幂等:
grant/revoke均可安全重复调用——已持有你发的授予时grant不重复发放;你无可撤的授予时revoke无副作用(状态区分见 DecorationGrantApi 参考)。 - 续期语义(只延长不缩短):已持有你发的有效授予,再次
grant时本次expiresAt比现有更晚(或留空升格永久)才刷新并返回RENEWED,否则返回ALREADY_HELD——取更晚值让重复 / 乱序重试都安全;要缩短或收回走revoke。注意这是"刷新窗口"不是"叠加时长":需要叠加的场景(如购买 / 兑换时长),请你自行记录当前到期日、算好累计后的绝对时刻传入。 - 持续型防"漏撤"(你的插件崩了 / 被卸载没来得及
revoke):grant时给一个短expiresAt,满足期间周期性重发续期(返回RENEWED),不续则自动过期失效。
Flux<GrantableDecoration> listGrantable(String categoryName); // 可发清单,categoryName 传 null = 全部
Mono<GrantResult> grant(GrantRequest request);
Mono<RevokeResult> revoke(RevokeRequest request);请求与响应:
GrantRequest { userName, decorationName, sourcePlugin, expiresAt?(留空=永久), reason? }
另有三参便捷构造 (userName, decorationName, sourcePlugin),等价于永久发放
RevokeRequest { userName, decorationName, sourcePlugin, reason? }
GrantableDecoration { name, displayName, type, rarityName, rarityDisplayName, categoryName }
GrantResult { status, grantName }
RevokeResult { status }
sourcePlugin 传你自己的插件标识(你 plugin.yaml 的 metadata.name)。用于来源记录与撤销隔离——revoke 只会撤你自己发放的那条授予,动不到别人(其他插件 / 站长后台)发的。实现只校验非空,不做前缀拦截;请不要占用别人的插件名。
发放结果 GrantResult.status:
| 状态 | 含义 |
|---|---|
GRANTED |
已新发放(他源是否发过与此无关) |
RENEWED |
已持有你发的授予,本次已延长有效期(仅新值更晚才刷新) |
ALREADY_HELD |
已持有你发的授予且本次有效期不更晚,幂等跳过 |
DECORATION_NOT_FOUND |
decorationName 不存在(含正在删除中的装饰) |
DECORATION_INACTIVE |
装饰未启用 |
DECORATION_NOT_EXTERNALLY_GRANTABLE |
该稀有度被站长禁止外发 |
USER_NOT_FOUND |
用户不存在 |
便捷方法 isHeld():status ∈ {GRANTED, RENEWED, ALREADY_HELD},即「调用后用户持有该装饰」——只关心结果、不关心新发 / 续期 / 已持有区别时,用它做归一判断。grantName 仅在这三个持有态有值(授予记录标识),其余状态为 null。
撤销结果 RevokeResult.status:
| 状态 | 含义 |
|---|---|
REVOKED |
已撤销你发的那条(用户若还有他源有效授予则仍持有该装扮) |
NOT_HELD |
用户无任何有效授予,幂等无操作 |
FORBIDDEN_NOT_OWNER |
你无可撤的授予,但他源(其他插件 / 后台)仍有——不可越权撤 |
参数校验:参数为空(userName / decorationName / sourcePlugin),或 expiresAt 不晚于当前时间(已是过去的有效期不会发放),会以 IllegalArgumentException 抛出(走 Mono 的 error 通道),与上面的业务状态区分。
grant / revoke 的 decorationName 就是装饰的 metadata.name(Halo 唯一标识,形如 asset-01hxyz…)。两种拿法,对应两种集成风格:
- 让站长复制填(最简单):站长在
互动 → 装饰 → 资产 → 编辑弹窗顶部能看到「装饰标识」并一键复制,粘到你插件的配置项里。 - 可视化选择(体验更好):调
listGrantable(categoryName)拉清单,在你插件的设置页做下拉,让站长按显示名选,你存下选中项的name。
listGrantable只返回当前能发的(已启用 + 所属稀有度允许外发),与grant的校验一致——清单里有的就一定发得出。
永久型场景。把所有引用 interaction-plus 类的逻辑收拢到一个独立组件(原因见降级与故障排查):
@Component
@RequiredArgsConstructor
public class DecorationGrantClient {
private final ExtensionGetter extensionGetter;
/** 用户连续签到达标时调用;decorationName 来自站长配置。 */
public Mono<Void> grantOnSigninStreak(String userName, String decorationName) {
return extensionGetter.getEnabledExtension(DecorationGrantApi.class)
.flatMap(api -> api.grant(
new GrantRequest(userName, decorationName, "my-checkin-plugin")))
.doOnNext(result -> log.info("发放结果 {}", result.status()))
.then()
// interaction-plus 未安装 / 调用异常时静默降级,不影响签到主流程
.onErrorResume(e -> Mono.empty());
}
}持续型则在「中断」时再调 api.revoke(new RevokeRequest(userName, decorationName, "my-checkin-plugin"))。
- 发放只进库存,不自动佩戴:
grant成功后装饰进入用户库存,用户需在「用户中心 → 装扮 → 我的装扮」自行佩戴,前台才会显示。身份标识不走库存、按角色映射即时生效,与本 API 无关。 - 你发的授予也可能被站长收回:站长可在后台撤销任何来源的授予(含你发放的),不要假设"发过就永远存在"。持续型场景的周期性重发会自然重建;一次性场景可按
grant返回状态感知。 - 稀有度准入:站长可关闭某些稀有度(如传说 / 限定)的「允许外部发放」开关。被关掉的装饰不会出现在
listGrantable,grant也会返回DECORATION_NOT_EXTERNALLY_GRANTABLE——这是站长的有意控制,你的插件按结果状态处理即可。 - 不要硬编码 ulid:装饰删了重建后
metadata.name会变。建议让站长配置 / 选择,而不是把某个asset-xxx写死在代码里。 - 网络重试安全:
grant/revoke幂等(见发放模型),可放心重试。
与其他读出口同源:本 API 与公开 HTTP API(
GET /identity/{userName})、Halo 模板 Finder(${interactionPlus.getIdentity(...)})共用同一聚合服务与缓存,返回内容、判空行为、对用户禁用 / 删除的处理三个出口一致,仅出口形态不同。只读、无副作用,可任意重试。
已有的两条读通道各有边界:公开 HTTP API 给浏览器前端 fetch 用,调用方必须持有 userName;Finder 注册进 Halo 模板引擎,任何由 Halo 渲染的 Thymeleaf 模板都可以调用(包括主题与独立插件前台页),但其他插件的 Java 后端受插件容器隔离,不能把 Finder 当进程内服务注入。
典型缺口:你想在自己插件的公开 API 响应里内嵌用户装扮(如评论插件给评论作者附带装扮数据),但 Halo 公开评论查询不返回评论者 / 回复者的 userName(只保留 displayName / avatar)——前端拿不到用户名,无从调用 HTTP 批量接口,查询必须发生在你的后端。本 API 就是这条通道。
口径是分角色的:文章 / 页面作者的 userName 在 Halo 本来就是公开的(
ContributorVo.name、作者页/authors/{name})——目标用户是作者时,前端直接调公开 HTTP API 即可,不必动用本 API。
只需要浏览器端渲染、且你自己能拿到 userName 的场景,用公开 HTTP API 即可,不必引本 API。
Mono<PublicIdentity> getIdentity(String userName); // 单查;查无 / 用户被禁用 = 空 Mono
Flux<PublicIdentity> listIdentities(Collection<String> userNames); // 批量;自动去重,上限 50- 批量自动忽略空白项并去重;去重后上限 50,超出以
IllegalArgumentException走 error 通道(与公开 HTTP 批量接口一致),更多请自行分批(评论分页等常见场景一般远小于此)。 - 查不到 / 被禁用的用户不出现在结果中(不占位、不报错),按
userName()关联即可;单个用户聚合失败不影响整批。入参为null/ 全空白返回空Flux。 getIdentity入参空白以IllegalArgumentException走 error 通道。- 返回的是当前佩戴的装扮与身份标识,不提供装饰墙全量列表。
DTO PublicIdentity 与 GET /identity/{userName} 共用同一聚合源,当前身份与佩戴装饰的展示字段口径一致;数据自包含、零二次查询:
PublicIdentity {
userName String 用户名(metadata.name)。进程内关联键;喂给 hip-* 的 data 模式时必须保留——默认跳转 /authors/{name} 用它拼 href
displayName String 显示名称
avatar String? 头像(绝对链接)
bio String? 公开简介
registeredAt Instant 注册时间(spec.registeredAt 缺失时回退资源创建时间,恒有值)
identityMarks List<IdentityMark> 身份标识(如「管理员」,按角色映射、无需佩戴),优先级降序
decorations WornDecorations 当前佩戴的装扮
stats Stats 互动统计(公开口径;含其他插件贡献项)
display DisplayConfig 站点级展示策略快照(按组件场景的开关 / 密度 / 跳转 / 无头像占位风格),非用户属性
}
IdentityMark { displayName, icon?, color?, priority } // icon 与 color 互斥:文字牌用 color,图标(data URL)/图片用 icon;displayName 同时作图标提示与裂图回落
Stats {
posts long 公开文章数(已发布、可见性公开)
comments long 公开评论数(已审核、未隐藏)
decorations DecorationCounts? 装扮持有计数;null = 用户关闭了公开装扮墙
extras List<ContributedStat> 其他插件经 UserStatContributor 贡献的统计项
}
DecorationCounts { total, badge, avatarFrame, title, nameStyle, cardBackground }
(有效授予且资产可用,按资产去重)
ContributedStat { source, key, label, value }
source = 来源插件 id(系统按 ExtensionDefinition 归属盖章,不可自报)
WornDecorations { 槽位为 null = 未佩戴 / 已失效 / 该类型被站长停用
avatarFrame Decoration? 头像框
title Decoration? 称号
primaryBadge Decoration? 主勋章
badgeShowcase List<Decoration> 勋章展示柜(≤8)
cardBackground Decoration? 名片背景
nameStyle Decoration? 昵称样式
}
Decoration {
assetName, type, displayName, url?, mediaType?,
titleText?, titleColor?, titleBackground?, titleBackgroundSecondary?, 称号专属
nameStyle?: { mode: solid|gradient, colors[] }, 昵称样式专属
rarityName?, rarityDisplayName?, rarityColor? 稀有度(已内联)
}
DisplayConfig {
identityLine { showTitle, showPrimaryBadge, showNameStyle, showIdentityMarks, identityLimit }
avatar { showFrame }
userCard { showTitle, showPrimaryBadge, showShowcase, showNameStyle,
showIdentityMarks, showAvatarFrame, showCardBackground,
showcaseBadgeLimit, identityLimit }
userCardLinkTemplate 昵称 / 用户卡头像的跳转链接模板({name} = 用户名),空表示不跳转
avatarFallbackStyle 无头像占位:halo 灰底首字母 / hash 按显示名着色;只作用于内置 renderAvatar,不改 avatar 字段
}
// 站点级策略,非用户属性;自行渲染可整个忽略(见下条)
// 类型总闸不在这里:关了对应 decorations 槽位 / identityMarks 已是空
- 列表字段永不为
null(无则空列表)。 display是站点级展示策略,不是该用户的属性:这几项全站一份,与查的是谁无关,随站长改设置而变(缓存会一并失效)。内联在此只为让data模式零二次查询——把整份喂给 hip-* 就能画。自己渲染界面的消费方可以整个忽略它;尤其别拿userCard.identityLimit之类去裁剪并非用户卡的界面,那是按本插件的组件场景定的,不是给你的场景定的。userName不是可剥的内部字段:内嵌进你的公开响应、再喂给 hip-* 的data时必须带上。runtime 用它和display.userCardLinkTemplate算profileUrl;缺了头像 / 名字不跳转。站长清空跳转模板则两处一起关,与字段在不在无关。- 称号 = 名称 + 可选的图,没有形态开关:
titleText恒非空;titleColor/titleBackground/titleBackgroundSecondary可空(#RGB/#RRGGBB/#RRGGBBAA,8 位带透明度;空=无专属色 / 无底)。url有值时是称号图、同时titleText作它的替代文本与加载失败兜底。行内场景(评论、列表)建议只用titleText——称号图多是横条插画,缩到一行文字的高度图里的字会看不清;图适合用户卡这类有垂直空间的位置。
浏览器请求你的评论列表接口(不变)→ 你在后端组装作者 DTO 时批量查询、附加装扮 → 前端零额外请求。
把所有引用 interaction-plus 类的逻辑收拢到一个独立组件(原因见降级与故障排查):
@Component
@RequiredArgsConstructor
public class AuthorIdentityClient {
private final ExtensionGetter extensionGetter;
/**
* 批量查询作者公开身份,按 userName 索引。
* interaction-plus 缺席 / 查询异常时返回空 Map,评论主流程不受影响。
*/
public Mono<Map<String, PublicIdentity>> fetchIdentities(Collection<String> userNames) {
return extensionGetter.getEnabledExtension(PublicIdentityQueryApi.class)
.flatMapMany(api -> api.listIdentities(userNames))
.collectMap(PublicIdentity::userName)
.onErrorResume(e -> Mono.just(Map.of()));
}
}组装响应时(示意):
// 每条评论:owner 为登录用户 → 从 Map 取身份数据附加到作者对象,
// 匿名评论 / 查不到 → 不附加,接口行为与集成前完全一致。
// 整份 PublicIdentity 可直接当 hip-* 的 data(含 userName):
// 缺了它,卡和身份行就算到了 display.userCardLinkTemplate 也拼不出跳转。
var identity = identityMap.get(comment.getSpec().getOwner().getName());
if (identity != null) {
authorVo.setIdentity(identity);
}- 只读可重试:无副作用,重试 / 缓存随意。interaction-plus 侧已带聚合缓存(TTL 由站长配置),高频调用一般无需你再自建缓存。
- 展示开关归站长与用户:站长可全局停用某类装扮(对应槽位变
null)、用户可更换 / 取下佩戴——不要假设某槽位一定有值,也不要缓存过久。
方向与前两个 API 相反:不是你调用 interaction-plus,而是你提供实现、interaction-plus 在聚合用户身份时调用你。
把你插件领域内的用户统计项(问答插件的「采纳数」、打赏插件的「获赏数」等)贡献到 interaction-plus 的用户卡数据行与公开身份数据中。数据始终保存在你自己的模型里——interaction-plus 只在聚合公开身份(缓存未命中)时调用一次,结果随公开身份整体缓存(站长可配 TTL,默认 30 秒),并经 hip-* 组件、Halo 模板 Finder、公开 HTTP API 三个读出口同源下发。
本扩展点只接收展示用的业务统计(如采纳数、获赏数);积分、经验等记账资产不属于这个接口。
第 1 步:实现 UserStatContributor(依赖声明与快速开始相同):
@Component
public class QaUserStatContributor implements UserStatContributor {
private final AnswerService answerService; // 你自己的领域服务
@Override
public Flux<UserStat> getUserStats(String userName) {
// 查你自己的模型;建议内部自带缓存,保证快速返回
return answerService.countAccepted(userName)
.map(count -> new UserStat("accepted", "采纳", String.valueOf(count)))
.flux();
}
}第 2 步:在你插件的资源 yaml 中声明 ExtensionDefinition(必须):
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionDefinition
metadata:
name: your-plugin-user-stat-contributor
spec:
className: com.example.yourplugin.QaUserStatContributor
extensionPointName: user-stat-contributor
displayName: "问答统计贡献"为什么必须:每个贡献项对外携带的来源插件 id 由系统盖章——取自该 ED 上 Halo 自动打的 plugin.halo.run/plugin-name label,你无法自报也无法冒充他人;查不到 ED 归属的实现,其贡献项会被整体忽略(interaction-plus 侧打 warn 日志)。消费方以 来源插件 id + key 为完整标识,不同插件的同名 key 互不冲突。
第 3 步:按快速开始声明可选依赖(本插件缺席时你的实现只是无人调用,不影响你启动)。
| 项 | 约束 |
|---|---|
key |
^[a-z0-9][a-z0-9-]{0,31}$,插件内唯一 |
label |
展示名,1-16 字符 |
value |
展示值文本(如 "23"、"Lv.16",格式化由你决定、原样展示),1-32 字符 |
| 项数 | 每插件每用户至多 5 项,超出截断 |
| 时间预算 | 单贡献方总超时 800ms:超时或出错,本次聚合丢弃你的全部项,不影响其他数据与其他贡献方 |
实现建议:无数据返回空 Flux(不要 error);慢查询自建缓存——你的返回结果已随公开身份缓存,但缓存未命中时的那一次调用计入你的 800ms 预算。
- 可选依赖缺席:
interaction-plus没装时,它的类(含上述两个接口)不在 classpath。务必把引用这些类的代码隔离在单独的类 / 组件里,避免在主流程触发NoClassDefFoundError;并对调用结果做onErrorResume/ 空值降级。可参考官方plugin-live2d对plugin-ai-foundation的可用性判断写法。 - 只读取业务需要的 DTO 字段,不要对整个对象做穷举式字段校验。
getEnabledExtension(接口类) 取不到实现时只返回空 Mono,核心侧不打任何日志——「没装」和「装了但失联」表现完全一样,无法从代码侧区分。请按序排查:
interaction-plus是否已安装且已启用(后台 → 插件列表确认,装了但停用同样取不到);- 站长是否在 Halo「设置 → 扩展点」里给该扩展点指定过实现:若指定后
interaction-plus被卸载 / 重装,配置仍指向已不存在的实现,会静默失联——即便重新装回也取不到,需站长到扩展点设置里重新选择(或清除该项); - 确认注入的是核心的
run.halo.app.plugin.extensionpoint.ExtensionGetter,且调用getEnabledExtension(而非getExtensions)。
| 项 | 要求 |
|---|---|
| 运行环境 | Interaction Plus 要求 Halo >= 2.25;ExtensionGetter 对插件开放需 Halo >= 2.18 |
| 依赖版本 | Interaction Plus 1.0.0;api 构件 1.0.0 |
| 构建环境 | api 模块使用 Java 21 字节码,消费方使用 JDK 21 且编译目标(release / toolchain)≥ 21;对外类型只依赖 org.pf4j.ExtensionPoint 与 Reactor,相关依赖均为 compileOnly |
如果你的插件在浏览器端渲染用户身份(评论插件是典型),直接写 hip-* 标签即可,但请带上 scene 属性声明场景:
<hip-user-avatar user-name="tim" scene="comment"></hip-user-avatar>
<hip-user-identity user-name="tim" scene="comment"></hip-user-identity>- 默认不要指定尺寸。没人覆盖时,多大由站长模板按
scene决定(内置默认:comment的头像是 36px)。组件没有size属性,尺寸不走标签属性。 - 要改你自己输出的那些:在标签或容器上设
--hip-avatar-size(只影响你种下去的实例,不是全站这个 scene 词)。用法见前台适配指南 · 调用方覆盖头像尺寸。 - 推荐词表:
comment(评论)/post(正文)/moment(瞬间)/sidebar(侧栏)/profile(个人页)/list(列表)。插件不校验取值,但各造各的词会让站长为一堆同义词写规则。 - 六个词在头像的内置模板里都有对应尺寸;身份行与用户卡的内置模板不消费
scene(站长可自行写规则)。哪个组件吃哪些词见场景分档。 scene是场景类别不是位置实例:你的插件若有两处语义不同的位置,别都填comment,换个更具体的词。需要第二个维度时,宿主上的任何属性都能当 CSS 挂点(如data-area="..."),不必挤在scene里。- 不填也能跑,走模板的默认形态。
- 前提是页面已引入 runtime(见前台适配指南)。你的插件若有独立路由页,需要自己引一次。
- 另起自己的 CSS 变量或 scene 词想让站长写进自定义模板时,你必须自己给站长出说明。变量写清名字、用在哪、不设时怎样;scene 写清词是什么、用在哪。本插件文档只保证
--hip-avatar-size和推荐六词,不替你列那些名字。推荐词能用就别另起,免得站长为一堆同义词写规则。