插件开发指南
RustBill 插件系统基于 Rune 脚本引擎 —— 用 Rust 原生嵌入的静态类型脚本语言编写插件,零 Rust 工具链依赖,一把文本编辑器即可开发。插件源码(.rn 文件)存储在数据库中,运行时由服务器编译并执行。
目录
- Rune 脚本引擎
- 双表模型:plugins 与 plugin_interfaces
- 四种插件类型
- Host API 参考
- 契约函数详解
- 配置展开模式
- 开发工作流
- 完整示例
- 常见陷阱
- Rune 语法速查
Rune 脚本引擎
什么是 Rune
Rune 是一个用 Rust 编写的嵌入式脚本语言,具有以下特点:
- 静态类型:编译时类型检查,避免运行时类型错误
- Rust 风格语法:
fn、let、async/await、match、if/else等语法接近 Rust - 内存安全:无 GC,通过所有权系统保证内存安全
- 原生性能:编译为字节码在虚拟机中执行,无 FFI 开销
RustBill 使用 Rune 0.14 版本(default-features = false,仅 std, alloc, emit feature)。
执行模型:模块缓存 + 短生命周期虚拟机
编译阶段:Rune 源码 + Host API 模块 → prepare() → build() → Unit(缓存)
执行阶段:每次调用创建新 VM → 执行函数 → 销毁 VM
热重载: 编辑数据库中源码 → evict() 清除缓存 → 下次调用自动重新编译
每次函数调用的生命周期:
- 从
RwLock<HashMap<ScriptKey, Unit>>缓存中获取已编译的Unit - 基于
Unit创建新的虚拟机(VM) - 将配置和业务参数作为函数实参注入 VM
- 执行目标函数
- 获取返回值并销毁 VM
每次调用创建新 VM 的设计确保了请求间完全隔离,一个脚本的执行不会影响其他请求。
编译时验证
插件脚本在编译阶段即进行契约验证:
- 函数存在性检查:按插件类型逐一验证所有必需函数是否已声明
- 函数签名检查:验证参数数量(统一为 2 个参数)
- 语法检查:Rune 编译器在
build()阶段捕获全部语法错误
编译失败的原因会记录到服务器日志,Admin UI 插件管理页面也会展示错误信息。
零 FFI,纯原生执行
与传统的 FFI 插件系统不同,Rune 脚本的 Host API(如 HTTP 请求、SMTP 发信、MD5 签名等)是在 Rust 端实现的原生函数,在 VM 中注册为原生模块后直接调用。脚本开发者无需关心内存布局、ABI 兼容性等底层问题。
双表模型:plugins 与 plugin_interfaces
RustBill 使用两张数据库表管理插件生命周期:
plugins 表 —— 插件定义
存储从 plugins/*.rn 文件扫描到的脚本定义。无启用/禁用状态 —— 所有扫描到的插件自动存在于该表中。
| 列名 | 类型 | 说明 |
|---|---|---|
id | UUID | 主键 |
plugin_type | TEXT | 插件类型(见下方枚举) |
plugin_id | TEXT | 插件标识符(如 yipay、webhook) |
has_admin_page | BOOLEAN | 是否包含 admin_page() 函数(预计算列) |
version | TEXT | 版本号(从 fn version() 提取) |
plugin_interfaces 表 —— 接口实例
插件脚本是模板,接口实例是具体配置。同一个插件定义可以创建多个接口实例,每个实例有独立的配置和启用状态。
| 列名 | 类型 | 说明 |
|---|---|---|
id | UUID | 主键 |
plugin_def_id | UUID | FK → plugins(id) |
plugin_type | TEXT | 插件类型 |
plugin_id | TEXT | 插件标识符 |
display_name | TEXT | 显示名称(用户自定义,如“主站易支付“) |
config | JSONB | 运行时配置值 |
config_schema | JSONB | 配置表单 Schema(从脚本提取) |
enabled | BOOLEAN | 是否启用 |
script_source | TEXT | 脚本完整源码 |
script_hash | TEXT | 源码哈希(用于变更检测) |
唯一约束:UNIQUE(plugin_type, plugin_id, display_name)
PluginScanner —— 自动扫描同步
服务器启动时及此后每 5 分钟,PluginScanner 扫描 plugins/ 目录下的 .rn 文件:
plugins/*.rn
│
▼ 扫描文件,检测新增/更新
PluginScanner
│
├── 新文件 → INSERT 到 plugins 表
├── 已存在 → 比较版本号
│ ├── 文件版本 > DB 版本 → UPDATE plugins + 级联同步所有子接口 config_schema
│ └── 文件版本 <= DB 版本 → 仅更新 script_source/script_hash(保护人工修改)
└── 已存在接口 → 自动按 plugin_type + plugin_id 链接
版本管理与级联同步
.rn 脚本中通过 fn version() 声明语义化版本号:
#![allow(unused)]
fn main() {
fn version(_config, _unused) {
"1.0.0"
}
}
版本比较规则:
- 文件系统版本 > DB 版本 → 更新插件定义 + 级联同步所有关联接口实例的
config_schema(确保 schema 与最新脚本一致) - 文件系统版本 <= DB 版本 → 仅更新
script_source和script_hash,不同步接口(保护管理员对配置 schema 的手工修改) - 无
fn version()→ 版本默认为"0.0.0"(最老,任何非零版本都触发更新)
四种插件类型
| 类型 | plugin_type DB 值 | 说明 | 函数数量 |
|---|---|---|---|
| 自建 Provider | first_party_provider | 自有基础设施(KVM、Incus) | 12 |
| 上游 Provider | upstream_provider | 上游分销(RustBill 上游) | 15 |
| 支付网关 | gateway | 支付渠道(易支付、银行转账) | 7 |
| 通知渠道 | notifier | 通知渠道(Webhook、邮件) | 6 |
所有四种类型均支持可选的 admin_page() 函数,用于在 Admin UI 中渲染自定义管理页面。
Host API 参考
所有 Host API 在脚本中通过 rustbill_host::函数名(参数) 调用。Rune Function trait 硬限制每次调用最多 5 个参数;所有参数和返回值均为 String 类型(或单元类型 ())。复杂数据通过 JSON 字符串传递。
日志函数
| 函数 | 类型 | 签名 | 说明 |
|---|---|---|---|
log_info(msg) | sync | &str → () | 记录 info 级别日志 |
log_warn(msg) | sync | &str → () | 记录 warn 级别日志 |
log_error(msg) | sync | &str → () | 记录 error 级别日志 |
#![allow(unused)]
fn main() {
rustbill_host::log_info(`Webhook sent to ${url}`);
rustbill_host::log_error(`Payment signature mismatch for ${payment_id}`);
}
HTTP 请求
| 函数 | 类型 | 签名 | 说明 |
|---|---|---|---|
http_get(url) | async | String → String | HTTP GET,返回响应 body |
http_post(url, body, content_type) | async | 3×String → String | HTTP POST,返回响应 body |
http_post_bytes(url, body) | async | 2×String → String | 二进制 POST(用于 gRPC-Web) |
http_post_with_headers(url, body, content_type, headers_json) | async | 4×String → String | POST 含自定义 Headers |
http_request_with_cert(method, url, body, content_type, cert_json) | async | 5×String → String | mTLS HTTP 请求 |
http_request_with_cert 的 cert_json 参数格式:
{"cert": "-----BEGIN CERTIFICATE-----\n...", "key": "-----BEGIN RSA PRIVATE KEY-----\n..."}
返回值格式:
{"status": 200, "headers": {"Content-Type": "..."}, "body": "..."}
注意:http_request_with_cert 返回的是结构化 JSON,而非原始 body 字符串。需用 json_get 提取 body 字段获取实际响应体。
#![allow(unused)]
fn main() {
async fn health_check(config_json, _unused) {
let api_url = rustbill_host::json_get(config_json, "api_url");
let cert_pem = rustbill_host::json_get(config_json, "cert_pem");
let key_pem = rustbill_host::json_get(config_json, "key_pem");
let cert_json = `{"cert":"${cert_pem}","key":"${key_pem}"}`;
let resp = rustbill_host::http_request_with_cert("GET", `${api_url}/1.0`, "", "", cert_json);
let status = rustbill_host::json_get(resp, "status"); // HTTP 状态码
let body = rustbill_host::json_get(resp, "body"); // 响应体
if status == "200" { "true" } else { "false" }
}
}
加密与哈希
| 函数 | 类型 | 签名 | 说明 |
|---|---|---|---|
md5_hex(s) | sync | &str → String | 计算 MD5 哈希并返回十六进制字符串 |
#![allow(unused)]
fn main() {
let params = `pid=${pid}&type=alipay&out_trade_no=${payment_id}`;
let sign = rustbill_host::md5_hex(`${params}${key}`);
}
SMTP 邮件
| 函数 | 类型 | 签名 | 说明 |
|---|---|---|---|
smtp_send(config_json, email_json) | sync | 2×String → () | 通过 SMTP 发送邮件 |
config_json 格式:
{"smtp_host": "smtp.example.com", "smtp_port": "465", "from": "[email protected]", "username": "user", "password": "pass", "encryption": "ssl"}
email_json 格式:
{"to": "[email protected]", "subject": "订单确认", "body": "您的订单已确认...", "content_type": "text/plain"}
KV 状态存储
| 函数 | 类型 | 签名 | 说明 |
|---|---|---|---|
state_get(key) | async | String → String | 读取 KV 状态值 |
state_set(key, value) | async | 2×String → () | 写入 KV 状态值 |
状态自动以 interface_id 为命名空间隔离,不同接口实例间的 key 互不冲突。
典型用途:支付回调去重、银行转账对账状态。
#![allow(unused)]
fn main() {
// 回调去重
let cached = rustbill_host::state_get(`cb:${payment_id}`);
if cached != "" {
// 已处理过,直接返回
return `{"status":"duplicate"}`;
}
rustbill_host::state_set(`cb:${payment_id}`, "1");
}
JSON 处理
| 函数 | 类型 | 签名 | 说明 |
|---|---|---|---|
json_get(json_str, path) | sync | 2×String → String | 按路径提取 JSON 字段 |
json_array_len(json_str, path) | sync | 2×String → i64 | 获取 JSON 数组长度 |
json_stringify(value) | sync | any → String | Rune 值序列化为 JSON 字符串 |
json_get 路径语法:
| 路径 | 对应 JSON Pointer | 说明 |
|---|---|---|
key | /key | 根级字段 |
parent.child | /parent/child | 嵌套字段 |
arr[0] | /arr/0 | 数组索引 |
arr[0].name | /arr/0/name | 数组元素字段 |
meta.state.network.eth0.addresses[0].address | /meta/state/network/eth0/addresses/0/address | 深层嵌套 |
不存在时返回空字符串 ""。
#![allow(unused)]
fn main() {
let name = rustbill_host::json_get(raw, "products[0].name");
let count = rustbill_host::json_array_len(raw, "products");
let json = rustbill_host::json_stringify(#{
payment_id: "123",
status: "success",
});
}
契约函数详解
关键约定:所有契约函数(含 config_schema、version、health_check 等)必须声明 2 个参数 (config, _unused)。引擎调用时固定传入 2 个值:合并后的 JSON 配置 + 占位 unit 值。contracts.rs 中 param_count 统一为 2。违反此约定会导致编译错误。
返回值约定
- Provider 适配器通过
rune::from_value::<String>反序列化返回值,因此 所有函数返回纯字符串值(不包装Ok/Err)。错误用"ERROR: 描述"前缀字符串表示。 - Gateway/Notifier 适配器可处理
Ok/Err类型。
PaymentGateway(支付网关)— 7 个必需函数
fn config_schema(config, _unused) → JSON Schema 对象
fn initialize(config, _unused) → Ok(())
async fn create_payment(config..., payment_id, description, money) → 支付结果对象
async fn query_payment(config..., payment_id) → 支付状态对象
async fn handle_callback(config..., payload) → 支付回调对象
async fn refund(config..., payment_id, amount) → 退款结果对象
fn health_check(config, _unused) → Ok(true)
create_payment 返回值
#![allow(unused)]
fn main() {
#{
gateway_payment_id: "外部支付单号", // 必填,String
payment_url: Some("支付跳转URL"), // Option<String>
qr_code: None, // Option<String> — 二维码数据
instructions: None, // Option<String> — 支付说明
}
}
query_payment / handle_callback / refund 返回值
#![allow(unused)]
fn main() {
#{
payment_id: "支付单号", // 必填,String
gateway_tx_id: None, // Option<String> — 网关交易流水号
status: "success", // String — success / pending / failed / refunded
paid_at: None, // Option<String> — 支付时间
raw_response: Some("网关原始响应"), // Option<String> — 调试用
}
}
Notifier(通知渠道)— 6 个必需函数
fn config_schema(config, _unused) → JSON Schema
fn initialize(config, _unused) → Ok(())
async fn send(config..., msg) → Ok(())
fn supported_events(config, _unused) → 事件类型数组 []
fn templates(config, _unused) → 通知模板对象 #{}
fn health_check(config, _unused) → Ok(true)
send 函数接收的 msg 是一个在调用时注入的 map,包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
event_type | String | 事件类型,如 "order.paid" |
title | String | 通知标题 |
body | String | 通知正文 |
recipient | String | 接收人(通常为邮箱地址) |
#![allow(unused)]
fn main() {
async fn send(merged, _unused) {
let event_type = rustbill_host::json_get(merged, "event_type");
let title = rustbill_host::json_get(merged, "title");
let body = rustbill_host::json_get(merged, "body");
let recipient = rustbill_host::json_get(merged, "recipient");
// 构建通知 payload 并发送...
Ok(())
}
}
supported_events 返回 [](空数组)表示监听所有事件;返回具体事件列表则仅监听列表中的事件:
#![allow(unused)]
fn main() {
fn supported_events(merged, _unused) {
["order.created", "order.paid", "order.cancelled", "instance.ready"]
}
}
FirstPartyProvider(自建 Provider)— 12 个必需函数
config_schema spec_template sync_products
initialize query_price create_instance
get_instance_status terminate_instance execute_action
health_check instance_detail_sections instance_actions
spec_template — 规格模板
返回 JSON 字符串(非 Rune 对象),定义用户在创建实例时可选的配置项。
#![allow(unused)]
fn main() {
fn spec_template(config, _unused) {
`{"groups":[{...}],"fields":[{...}]}`
}
}
SpecField 结构(fields 数组和 groups[].fields 数组中的元素):
| 字段 | 类型 | 说明 |
|---|---|---|
key | String | 字段唯一标识 |
label | String | 显示标签 |
field_type | String | 类型:slider / integer / select / region / os_options |
required | Boolean | 是否必填 |
display_order | Integer | 排序 |
min / max | Integer | slider/integer 的范围 |
default_value | String | 默认值 |
step | Integer | 步长 |
unit | String | 单位(如 "核"、"GB") |
icon | String | 图标名(cpu / memory / disk / network / server / globe / monitor) |
group | String | 所属分组 key |
options | Array | select 类型的选项 [{value, label, description}] |
description | String | 可选描述文本 |
SpecGroup 结构(groups 数组):
| 字段 | 类型 | 说明 |
|---|---|---|
key | String | 分组唯一标识 |
label | String | 分组显示名 |
display_order | Integer | 排序 |
icon | String | 图标名 |
fields | Array | 该分组下的字段列表 |
fields 顶层数组为平铺模式(无分组),groups 数组为分组模式。前端按分组优先渲染。
完整示例见下方 Incus Provider 示例。
create_instance 返回值
{"instance_id": "provider-abc123", "status": "Running", "ip_address": "1.2.3.4", "created_at": ""}
get_instance_status 返回值
返回以下状态字符串之一:"running" / "stopped" / "terminated" / "pending" / "error"
instance_detail_sections 返回值
返回 JSON 字符串,是一个 section 数组。每个 section:
[
{
"kind": "properties",
"title": "Server Config",
"order": 1,
"content_html": "<div>...</div>",
"iframe_url": null,
"data_json": null
},
{
"kind": "iframe",
"title": "Console",
"order": 2,
"content_html": null,
"iframe_url": "https://...",
"data_json": null
}
]
content_html渲染为<iframe srcDoc sandbox="allow-scripts">iframe_url渲染为<iframe src>外部资源data_json渲染为结构化数据卡片
instance_actions 返回值
返回 JSON 字符串,是一个 action 数组:
[
{"id": "start", "label": "Boot", "style": "Primary", "confirmation": "", "enabled": true},
{"id": "stop", "label": "Shutdown", "style": "Default", "confirmation": "Force shutdown may cause data loss. Continue?", "enabled": true}
]
style 可选值:"Primary" / "Default" / "Danger"。confirmation 为空时不弹确认框。
execute_action 返回值
{"success": true, "message": "Instance started", "data": "..."}
UpstreamProvider(上游 Provider)— 15 个必需函数
FirstPartyProvider 的全部 12 个函数 + 3 个上游专属函数:
sync_upstream_instances — 同步上游实例列表
import_upstream_instance — 从上游导入单个实例
get_upstream_balance — 查询上游余额
配置展开模式
插件配置 JSON(plugin_interfaces.config)的键值在函数调用时被展开为独立的 JSON 字段,与业务参数合并后整体传入。脚本通过 rustbill_host::json_get(merged, "key") 逐一提取。
示例:假设一个网关插件的 config 为:
{
"api_url": "https://pay.example.com",
"pid": "1001",
"key": "sk-xxxxxxxx"
}
当调用 create_payment 时,适配器将 config 字段与业务参数合并:
合并后的 JSON = config 字段 + business 字段
脚本中按需取值:
#![allow(unused)]
fn main() {
async fn create_payment(merged, _unused) {
let api_url = rustbill_host::json_get(merged, "api_url"); // 来自 config
let pid = rustbill_host::json_get(merged, "pid"); // 来自 config
let key = rustbill_host::json_get(merged, "key"); // 来自 config
let payment_id = rustbill_host::json_get(merged, "payment_id"); // 业务参数
let amount = rustbill_host::json_get(merged, "amount"); // 业务参数
// ...
}
}
每个接口实例有独立的 config 值,同一插件的不同实例(如“主站易支付“和“备用易支付“)可配置不同的 API 端点和密钥。
开发工作流
1. 编写脚本
在 plugins/ 目录下创建 .rn 文件。文件命名约定为 {type}-{id}.rn:
gateway-yipay.rn— 支付网关插件,id 为yipaynotifier-webhook.rn— 通知插件,id 为webhookprovider-incus.rn— Provider 插件,id 为incus
插件类型自动推断规则:
| 文件名模式 | 推断类型 |
|---|---|
gateway-*.rn | payment_gateway |
notifier-*.rn | notifier |
provider-rustbill.rn | upstream_provider(特例) |
provider-*.rn(非 rustbill) | first_party_provider |
2. 激活脚本
两种方式:
- 重启服务器:PluginScanner 在启动时扫描
plugins/目录 - 等待自动扫描:每 5 分钟后台任务自动检测文件变更
扫描后,新插件会插入 plugins 表。到 Admin UI 的插件管理页面即可看到新条目。
3. 创建接口实例
通过 Admin UI → 插件管理:
- 选择插件定义 → 点击“创建接口“
- 输入显示名称(如“主站易支付“)
- 系统自动从脚本提取
config_schema并存入plugin_interfaces.config_schema - 接口创建后,在配置标签页编辑
configJSON 填入实际参数
4. 启用并使用
- 将接口的
enabled设为true - 接口立即注册到
PluginRegistry,随后业务逻辑即可引用该接口 - 例如,在商品管理中关联某个 Provider 接口,或在通知管理中启用某个 Notifier 接口
5. 热更新脚本
- 在 Admin UI 插件管理页的“源码“标签页编辑脚本
- 保存后系统自动调用
ScriptEngine.evict(key)清除缓存 - 下一次函数调用触发时自动重新编译
- 无需重启服务器,零停机更新
6. 完整生命周期
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 编写 .rn │────▶│ 扫描/插入 │────▶│ 创建接口 │────▶│ 启用使用 │
│ 脚本文件 │ │ plugins 表 │ │ 实例+配置 │ │ Registry │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 重新编译 │◀────│ 清除缓存 │◀────│ 编辑源码 │
│ 即插即用 │ │ evict() │ │ Admin UI │
└─────────────┘ └─────────────┘ └─────────────┘
完整示例
示例一:PaymentGateway(简易支付网关)
以下是一个完整的支付网关插件,模拟在线支付跳转逻辑:
#![allow(unused)]
fn main() {
// gateway-simplepay.rn — 简易支付网关示例
// 所有函数接收 (merged_json, _unused),config 字段与业务字段合并传入
fn config_schema(_config, _unused) {
#{
type: "object",
required: ["api_url", "app_id", "app_secret"],
properties: #{
api_url: #{type: "string", description: "支付接口地址"},
app_id: #{type: "string", description: "应用ID"},
app_secret: #{type: "string", description: "应用密钥", format: "password"},
notify_url: #{type: "string", description: "异步通知地址"},
return_url: #{type: "string", description: "同步跳转地址(可选)"},
},
}
}
fn version(_config, _unused) {
"1.0.0"
}
fn initialize(merged, _unused) {
Ok(())
}
// 创建支付订单
async fn create_payment(merged, _unused) {
// 从 config 提取
let api_url = rustbill_host::json_get(merged, "api_url");
let app_id = rustbill_host::json_get(merged, "app_id");
let app_secret = rustbill_host::json_get(merged, "app_secret");
let notify_url = rustbill_host::json_get(merged, "notify_url");
let return_url = rustbill_host::json_get(merged, "return_url");
// 从业务参数提取
let payment_id = rustbill_host::json_get(merged, "payment_id");
let description = rustbill_host::json_get(merged, "description");
let amount = rustbill_host::json_get(merged, "amount");
// 构建签名
let raw = `${app_id}${payment_id}${amount}${app_secret}`;
let sign = rustbill_host::md5_hex(raw);
// 构建 POST body
let body = `app_id=${app_id}&out_trade_no=${payment_id}&name=${description}&money=${amount}¬ify_url=${notify_url}&return_url=${return_url}&sign=${sign}`;
// 调用上游 API
let resp = rustbill_host::http_post(`${api_url}/api/create`, body, "application/x-www-form-urlencoded");
// 解析跳转 URL
let payment_url = if resp.starts_with("http") { resp } else { `${api_url}/pay/${payment_id}` };
rustbill_host::log_info(`Payment created: ${payment_id}`);
rustbill_host::json_stringify(#{
gateway_payment_id: payment_id,
payment_url: Some(payment_url),
qr_code: None,
instructions: None,
})
}
// 查询支付状态
async fn query_payment(merged, _unused) {
let api_url = rustbill_host::json_get(merged, "api_url");
let app_id = rustbill_host::json_get(merged, "app_id");
let app_secret = rustbill_host::json_get(merged, "app_secret");
let payment_id = rustbill_host::json_get(merged, "payment_id");
let body = `app_id=${app_id}&out_trade_no=${payment_id}`;
let resp = rustbill_host::http_post(`${api_url}/api/query`, body, "application/x-www-form-urlencoded");
let status = if resp.contains("SUCCESS") { "success" } else if resp.contains("FAIL") { "failed" } else { "pending" };
rustbill_host::json_stringify(#{
payment_id,
gateway_tx_id: None,
status,
paid_at: None,
raw_response: Some(resp),
})
}
// 处理异步回调
async fn handle_callback(merged, _unused) {
let payload = rustbill_host::json_get(merged, "payload");
let payment_id = rustbill_host::json_get(merged, "payment_id");
let app_secret = rustbill_host::json_get(merged, "app_secret");
// 验证签名
let sign = extract_param(payload, "sign");
let base = strip_sign(payload);
let expected = rustbill_host::md5_hex(`${base}${app_secret}`);
if sign != expected {
rustbill_host::log_error(`Callback signature mismatch for ${payment_id}`);
return "ERROR: invalid signature";
}
// 回调去重
let cached = rustbill_host::state_get(`cb:${payment_id}`);
if cached != "" {
rustbill_host::log_info(`Duplicate callback ignored: ${payment_id}`);
return rustbill_host::json_stringify(#{
payment_id,
gateway_tx_id: None,
status: "success",
paid_at: None,
raw_response: Some("duplicate"),
});
}
rustbill_host::state_set(`cb:${payment_id}`, "1");
rustbill_host::json_stringify(#{
payment_id,
gateway_tx_id: None,
status: "success",
paid_at: None,
raw_response: Some(payload),
})
}
// 发起退款
async fn refund(merged, _unused) {
let api_url = rustbill_host::json_get(merged, "api_url");
let app_id = rustbill_host::json_get(merged, "app_id");
let app_secret = rustbill_host::json_get(merged, "app_secret");
let payment_id = rustbill_host::json_get(merged, "payment_id");
let amount = rustbill_host::json_get(merged, "amount");
let body = `app_id=${app_id}&out_trade_no=${payment_id}&money=${amount}&app_secret=${app_secret}`;
let resp = rustbill_host::http_post(`${api_url}/api/refund`, body, "application/x-www-form-urlencoded");
rustbill_host::log_info(`Refund requested: ${payment_id} ${amount}`);
rustbill_host::json_stringify(#{
payment_id,
gateway_tx_id: None,
status: "refunded",
paid_at: None,
raw_response: Some(resp),
})
}
// 健康检查
fn health_check(merged, _unused) {
Ok(true)
}
// ── 辅助函数 ──
fn extract_param(query, name) {
let prefix = `${name}=`;
let start = query.find(prefix);
if start.is_none() { return ""; }
let begin = start.unwrap() + prefix.len();
let rest = query[begin:];
let end = rest.find("&");
if end.is_some() { rest[0:end.unwrap()] } else { rest }
}
fn strip_sign(query) {
let idx = query.find("&sign=");
if idx.is_some() { query[0:idx.unwrap()] } else { query }
}
}
示例二:FirstPartyProvider(简化版)
#![allow(unused)]
fn main() {
// provider-example.rn — 自建 Provider 示例
fn config_schema(_config, _unused) {
`{"type":"object","required":["api_url"],"properties":{"api_url":{"type":"string","description":"API 地址"},"api_token":{"type":"string","description":"API Token","format":"password"}}}`
}
fn version(_config, _unused) {
"1.0.0"
}
fn initialize(merged, _unused) {
"OK"
}
// 规格模板 — 定义用户可选配置项
fn spec_template(config, _unused) {
`{
"groups": [
{
"key": "compute",
"label": "计算资源",
"display_order": 1,
"icon": "cpu",
"fields": [
{
"key": "cpu_cores",
"label": "CPU 核数",
"field_type": "slider",
"required": true,
"display_order": 1,
"min": 1,
"max": 64,
"default_value": "2",
"step": 1,
"unit": "核",
"icon": "cpu",
"group": "compute",
"options": []
},
{
"key": "memory_gb",
"label": "内存",
"field_type": "slider",
"required": true,
"display_order": 2,
"min": 1,
"max": 512,
"default_value": "4",
"step": 1,
"unit": "GB",
"icon": "memory",
"group": "compute",
"options": []
}
]
},
{
"key": "location",
"label": "区域位置",
"display_order": 2,
"icon": "globe",
"fields": [
{
"key": "region",
"label": "区域",
"field_type": "region",
"required": true,
"display_order": 1,
"unit": "",
"icon": "globe",
"group": "location",
"options": []
}
]
}
]
}`
}
async fn sync_products(config_json, _unused) {
let api_url = rustbill_host::json_get(config_json, "api_url");
let api_token = rustbill_host::json_get(config_json, "api_token");
let headers = `{"Authorization":"Bearer ${api_token}","Content-Type":"application/json"}`;
let raw = rustbill_host::http_post_with_headers(`${api_url}/products`, "{}", "application/json", headers);
raw // 返回原始响应,由适配器解析
}
async fn query_price(config_json, _unused) {
`{"amount":"0.00","currency":"CNY","billing_cycle":"monthly"}`
}
async fn create_instance(config_json, _unused) {
let api_url = rustbill_host::json_get(config_json, "api_url");
let order_id = rustbill_host::json_get(config_json, "order_id");
let cpu = rustbill_host::json_get(config_json, "cpu_cores");
let mem = rustbill_host::json_get(config_json, "memory_gb");
rustbill_host::log_info(`Creating instance for order ${order_id}: ${cpu} cores, ${mem} GB`);
// ... 调用底层 API 创建实例 ...
`{"instance_id":"inst-${order_id}","status":"Running","ip_address":"1.2.3.4","created_at":""}`
}
async fn get_instance_status(config_json, _unused) {
let api_url = rustbill_host::json_get(config_json, "api_url");
let instance_id = rustbill_host::json_get(config_json, "instance_id");
// ... 查询实例状态 ...
"running"
}
async fn terminate_instance(config_json, _unused) {
let instance_id = rustbill_host::json_get(config_json, "instance_id");
rustbill_host::log_info(`Terminating instance ${instance_id}`);
""
}
async fn execute_action(config_json, _unused) {
let instance_id = rustbill_host::json_get(config_json, "instance_id");
let action = rustbill_host::json_get(config_json, "action");
rustbill_host::log_info(`Executing ${action} on ${instance_id}`);
""
}
fn health_check(config_json, _unused) {
"true"
}
fn instance_detail_sections(instance_json, _unused) {
let cpu = rustbill_host::json_get(instance_json, "server_spec.cpu_cores");
let mem = rustbill_host::json_get(instance_json, "server_spec.memory_gb");
`[{"kind":"properties","title":"Server Info","order":1,"content_html":"<div>CPU: ${cpu} cores, Memory: ${mem} GB</div>","iframe_url":null,"data_json":null}]`
}
fn instance_actions(instance_json, _unused) {
let status = rustbill_host::json_get(instance_json, "status");
if status == "running" {
`[{"id":"stop","label":"Shutdown","style":"Default","confirmation":"Are you sure?","enabled":true},{"id":"reboot","label":"Reboot","style":"Default","confirmation":"","enabled":true}]`
} else {
`[{"id":"start","label":"Boot","style":"Primary","confirmation":"","enabled":true}]`
}
}
}
示例三:Notifier(Webhook 通知)
#![allow(unused)]
fn main() {
// notifier-webhook.rn — Webhook 通知插件
fn config_schema(_config, _unused) {
#{
type: "object",
required: ["url"],
properties: #{
url: #{type: "string", description: "Webhook URL"},
secret: #{type: "string", description: "签名密钥(可选)"},
},
}
}
fn version(_config, _unused) {
"1.0.0"
}
fn initialize(merged, _unused) {
Ok(())
}
// 监听所有事件(返回空数组)
fn supported_events(merged, _unused) {
[]
}
// 通知模板(空对象表示使用系统默认模板)
fn templates(merged, _unused) {
#{}
}
async fn send(merged, _unused) {
let url = rustbill_host::json_get(merged, "url");
let event_type = rustbill_host::json_get(merged, "event_type");
let title = rustbill_host::json_get(merged, "title");
let body = rustbill_host::json_get(merged, "body");
let recipient = rustbill_host::json_get(merged, "recipient");
let payload = `{"event_type":"${event_type}","title":"${title}","body":"${body}","recipient":"${recipient}"}`;
rustbill_host::http_post(url, payload, "application/json");
rustbill_host::log_info(`Webhook sent to ${url}: ${event_type}`);
Ok(())
}
fn health_check(merged, _unused) {
Ok(true)
}
}
示例四:Notifier(邮件通知)
#![allow(unused)]
fn main() {
// notifier-email.rn — 邮件通知插件
fn config_schema(_config, _unused) {
#{
type: "object",
required: ["smtp_host", "from"],
properties: #{
smtp_host: #{type: "string", description: "SMTP 服务器地址"},
smtp_port: #{type: "integer", description: "SMTP 端口", default_value: "465"},
from: #{type: "string", description: "发件人邮箱"},
username: #{type: "string", description: "SMTP 用户名(可选)"},
password: #{type: "string", description: "SMTP 密码(可选)", format: "password"},
encryption: #{type: "string", description: "加密方式", enum: ["starttls", "ssl", "none"], default_value: "ssl"},
},
}
}
fn version(_config, _unused) {
"1.0.0"
}
fn initialize(merged, _unused) {
Ok(())
}
fn supported_events(merged, _unused) {
["order.created", "order.paid", "order.cancelled", "instance.ready", "billing.overdue"]
}
fn templates(merged, _unused) {
#{}
}
async fn send(merged, _unused) {
// 将 config 字段序列化为 SMTP 配置 JSON
let config = rustbill_host::json_stringify(#{
smtp_host: rustbill_host::json_get(merged, "smtp_host"),
smtp_port: rustbill_host::json_get(merged, "smtp_port"),
from: rustbill_host::json_get(merged, "from"),
username: rustbill_host::json_get(merged, "username"),
password: rustbill_host::json_get(merged, "password"),
encryption: rustbill_host::json_get(merged, "encryption"),
});
// 将 msg 字段序列化为邮件 JSON
let email = rustbill_host::json_stringify(#{
to: rustbill_host::json_get(merged, "recipient"),
subject: rustbill_host::json_get(merged, "title"),
body: rustbill_host::json_get(merged, "body"),
content_type: "text/plain",
});
rustbill_host::smtp_send(config, email);
Ok(())
}
fn health_check(merged, _unused) {
Ok(true)
}
}
示例五:config_schema 详解
config_schema 返回 JSON Schema 对象,Admin UI 据此渲染配置表单。支持以下字段类型:
| 类型 | 说明 | 对应表单控件 |
|---|---|---|
string | 字符串 | 文本输入框 |
string + format: "password" | 密码 | 密码输入框 |
string + enum | 枚举 | 下拉选择框 |
integer | 整数 | 数字输入框 |
number | 浮点数 | 数字输入框 |
完整示例:
#![allow(unused)]
fn main() {
fn config_schema(_config, _unused) {
#{
type: "object",
required: ["api_url", "api_key", "merchant_id"],
properties: #{
api_url: #{type: "string", description: "API 端点地址", default_value: "https://api.example.com"},
api_key: #{type: "string", description: "API 密钥", format: "password"},
merchant_id: #{type: "string", description: "商户号"},
timeout: #{type: "integer", description: "请求超时(秒)", default_value: "30", minimum: "1", maximum: "300"},
currency: #{type: "string", description: "结算货币", enum: ["CNY", "USD", "EUR"], default_value: "CNY"},
sandbox: #{type: "boolean", description: "沙箱模式", default_value: "false"},
},
}
}
}
常见陷阱
函数签名
-
所有函数必须声明 2 个参数
(config, _unused)。contracts.rs中param_count统一为 2。引擎调用时固定传入 2 个值,写 1 个参数会导致编译失败。 -
函数名必须与契约完全一致。例如
create_payment不是createPayment。拼写错误或大小写不匹配会被编译器报告为“缺少必需函数“。 -
参数数量必须匹配契约。每个插件类型有固定的必需函数集(Gateway 7 个、Notifier 6 个、Provider 12/15 个),缺少任何一个都会导致编译失败。
返回值
-
Provider 函数返回纯字符串值(不包装 Ok/Err)。Provider 适配器通过
rune::from_value::<String>反序列化返回值。返回Ok(xxx)会被反序列化失败。错误用"ERROR:描述"前缀。 -
返回值使用 Rune 对象字面量
#{},不是 JSON 字符串"{}"。#{key: "val"}是 Rune 对象,在 Rune 中是原生数据结构;"{\"key\":\"val\"}"是字符串,类型不同。 -
返回复杂数据(数组、嵌套对象)时用
json_stringify()。Rune 对象和数组不能直接跨 FFI 边界传递(除了通过 JSON 序列化)。例如:
#![allow(unused)]
fn main() {
// 正确 — Provider 返回值
rustbill_host::json_stringify(#{
gateway_payment_id: payment_id,
payment_url: Some(url),
})
// 错误 — 不能直接返回 Rune 数组
[1, 2, 3] // 适配器无法反序列化
}
Rune 语法
-
Rune 是静态类型。对象字面量用
#{key: value},不是 JavaScript 的{key: value}。#{}创建的是 Rune object 类型。 -
字符串插值用反引号(backticks)。
`text ${var} text`,不是 JavaScript 的模板字符串。 -
变量必须先声明后使用。与 Rust 相同,
let声明变量。Rune 不支持var。 -
Option 值用
Some(val)/None。Some(val)返回一个带值的 Option,None返回空 Option。序列化为 JSON 后分别变为"val"和null。
Host API 限制
-
Host API 函数最多 5 个参数(Rune Function trait 硬限制)。超过 5 个参数需合并为 JSON 字符串。如
http_request_with_cert的cert_json参数将证书和密钥合并为一个 JSON 字符串。 -
Host API 参数和返回值全是 String 类型(少数返回
()或i64)。不能用i32、f32等非 String 类型传递数据。数字通过String传递,脚本内部按需.parse::<f64>()或.parse::<i64>()。
热重载与调试
-
热重载不生效:检查 Admin UI 是否在保存源码后触发了
evict()。保存按钮会自动处理此逻辑。手动修改数据库需要自行调用 evict。 -
编译错误排查:查看服务器日志(
RUST_LOG=rustbill_server=debug)。编译错误信息包含具体的错误函数名和原因。 -
运行时错误排查:使用
rustbill_host::log_info/log_error在脚本中加入调试日志。日志会输出到服务器日志流中。
配置和部署
-
config_schema必须是合法的 JSON Schema(至少含type和properties键)。最小合法 schema:{"type":"object","properties":{}}。 -
plugins/目录路径由config.toml的[plugins].plugins_dir配置,默认为"./plugins"。 -
Admin Page 功能的
admin_page()是可选函数。所有四种插件类型均支持。存在该函数时,plugins.has_admin_page设为true,Admin UI 在插件管理页渲染自定义管理页面。
Rune 语法速查
以下涵盖插件开发中最常用的 Rune 语法:
变量与基本类型
#![allow(unused)]
fn main() {
// 变量声明
let x = 42;
let msg = "hello";
let flag = true;
let nothing = (); // 单元类型
// 字符串插值
let url = `https://api.example.com/v1/${resource}`;
// 数字类型
let count: i64 = 100; // 有符号 64 位整数
let price: f64 = 29.99; // 64 位浮点数
let n = "42".parse::<i64>().unwrap_or(0);
}
条件与循环
#![allow(unused)]
fn main() {
if status == "running" {
"active"
} else if status == "stopped" {
"inactive"
} else {
"unknown"
}
let mut i = 0;
while i < count {
// ...
i = i + 1;
}
}
对象与数组
#![allow(unused)]
fn main() {
// 对象字面量
let obj = #{key: "value", count: 42, enabled: true};
// 数组字面量
let arr = ["a", "b", "c"];
// Option
let maybe: Option = Some("present");
let nothing: Option = None;
}
函数定义
#![allow(unused)]
fn main() {
// 同步函数
fn add(a, b) {
a + b
}
// 异步函数
async fn fetch_data(url) {
let resp = rustbill_host::http_get(url);
rustbill_host::log_info(`Fetched ${url}`);
resp
}
// 带返回值的函数 — 最后一个表达式即为返回值
fn check(merged, _unused) {
let val = rustbill_host::json_get(merged, "key");
if val == "good" {
"OK"
} else {
"ERROR:bad value"
}
}
}
字符串与 Option 操作
#![allow(unused)]
fn main() {
// 字符串查找
let pos = "hello world".find("world");
// 字符串切片
let sub = "hello"[0:2]; // "he"
// 字符串拼接
let combined = `prefix_${id}`;
// 空字符串检查
if val == "" { /* ... */ }
if val.is_empty() { /* ... */ }
// Option unwrap
let v = opt.unwrap_or("default");
// 字符串长度
let n = s.len();
}
错误处理
#![allow(unused)]
fn main() {
// 返回错误(Gateway/Notifier 可用)
return Err("something went wrong");
// Provider 错误返回约定
return "ERROR: something went wrong";
// 值不存在时返回默认值
let val = rustbill_host::json_get(raw, "path");
if val == "" {
return "ERROR:missing required field";
}
}
附录:已实现的插件参考
以下是 RustBill 仓库中已实现的 6 个插件,可作为开发参考:
| 文件 | 类型 | 说明 |
|---|---|---|
plugins/gateway-yipay.rn | PaymentGateway | 易支付聚合支付 — MD5 签名 + HTTP |
plugins/gateway-banktransfer.rn | PaymentGateway | 银行转账 — 本地逻辑 + KV 对账 |
plugins/notifier-webhook.rn | Notifier | Webhook HTTP POST 通知 |
plugins/notifier-email.rn | Notifier | SMTP 邮件通知(lettre) |
plugins/provider-rustbill.rn | UpstreamProvider | RustBill 上游分销 — gRPC-Web |
plugins/provider-incus.rn | FirstPartyProvider | Incus 虚拟化 — mTLS REST API |
所有脚本源码均可通过 Admin UI 插件管理页面的“源码“标签页在线查看和编辑。