Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

插件开发指南

RustBill 插件系统基于 Rune 脚本引擎 —— 用 Rust 原生嵌入的静态类型脚本语言编写插件,零 Rust 工具链依赖,一把文本编辑器即可开发。插件源码(.rn 文件)存储在数据库中,运行时由服务器编译并执行。

目录

  1. Rune 脚本引擎
  2. 双表模型:plugins 与 plugin_interfaces
  3. 四种插件类型
  4. Host API 参考
  5. 契约函数详解
  6. 配置展开模式
  7. 开发工作流
  8. 完整示例
  9. 常见陷阱
  10. Rune 语法速查

Rune 脚本引擎

什么是 Rune

Rune 是一个用 Rust 编写的嵌入式脚本语言,具有以下特点:

  • 静态类型:编译时类型检查,避免运行时类型错误
  • Rust 风格语法fnletasync/awaitmatchif/else 等语法接近 Rust
  • 内存安全:无 GC,通过所有权系统保证内存安全
  • 原生性能:编译为字节码在虚拟机中执行,无 FFI 开销

RustBill 使用 Rune 0.14 版本(default-features = false,仅 std, alloc, emit feature)。

执行模型:模块缓存 + 短生命周期虚拟机

编译阶段:Rune 源码 + Host API 模块 → prepare() → build() → Unit(缓存)
执行阶段:每次调用创建新 VM → 执行函数 → 销毁 VM
热重载:  编辑数据库中源码 → evict() 清除缓存 → 下次调用自动重新编译

每次函数调用的生命周期:

  1. RwLock<HashMap<ScriptKey, Unit>> 缓存中获取已编译的 Unit
  2. 基于 Unit 创建新的虚拟机(VM)
  3. 将配置和业务参数作为函数实参注入 VM
  4. 执行目标函数
  5. 获取返回值并销毁 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 文件扫描到的脚本定义。无启用/禁用状态 —— 所有扫描到的插件自动存在于该表中。

列名类型说明
idUUID主键
plugin_typeTEXT插件类型(见下方枚举)
plugin_idTEXT插件标识符(如 yipaywebhook
has_admin_pageBOOLEAN是否包含 admin_page() 函数(预计算列)
versionTEXT版本号(从 fn version() 提取)

plugin_interfaces 表 —— 接口实例

插件脚本是模板,接口实例是具体配置。同一个插件定义可以创建多个接口实例,每个实例有独立的配置和启用状态。

列名类型说明
idUUID主键
plugin_def_idUUIDFK → plugins(id)
plugin_typeTEXT插件类型
plugin_idTEXT插件标识符
display_nameTEXT显示名称(用户自定义,如“主站易支付“)
configJSONB运行时配置值
config_schemaJSONB配置表单 Schema(从脚本提取)
enabledBOOLEAN是否启用
script_sourceTEXT脚本完整源码
script_hashTEXT源码哈希(用于变更检测)

唯一约束: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_sourcescript_hash不同步接口(保护管理员对配置 schema 的手工修改)
  • fn version() → 版本默认为 "0.0.0"(最老,任何非零版本都触发更新)

四种插件类型

类型plugin_type DB 值说明函数数量
自建 Providerfirst_party_provider自有基础设施(KVM、Incus)12
上游 Providerupstream_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)asyncString → StringHTTP GET,返回响应 body
http_post(url, body, content_type)async3×String → StringHTTP POST,返回响应 body
http_post_bytes(url, body)async2×String → String二进制 POST(用于 gRPC-Web)
http_post_with_headers(url, body, content_type, headers_json)async4×String → StringPOST 含自定义 Headers
http_request_with_cert(method, url, body, content_type, cert_json)async5×String → StringmTLS HTTP 请求

http_request_with_certcert_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)sync2×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)asyncString → String读取 KV 状态值
state_set(key, value)async2×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)sync2×String → String按路径提取 JSON 字段
json_array_len(json_str, path)sync2×String → i64获取 JSON 数组长度
json_stringify(value)syncany → StringRune 值序列化为 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_schemaversionhealth_check 等)必须声明 2 个参数 (config, _unused)。引擎调用时固定传入 2 个值:合并后的 JSON 配置 + 占位 unit 值。contracts.rsparam_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_typeString事件类型,如 "order.paid"
titleString通知标题
bodyString通知正文
recipientString接收人(通常为邮箱地址)
#![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 数组中的元素):

字段类型说明
keyString字段唯一标识
labelString显示标签
field_typeString类型:slider / integer / select / region / os_options
requiredBoolean是否必填
display_orderInteger排序
min / maxIntegerslider/integer 的范围
default_valueString默认值
stepInteger步长
unitString单位(如 "核""GB"
iconString图标名(cpu / memory / disk / network / server / globe / monitor
groupString所属分组 key
optionsArrayselect 类型的选项 [{value, label, description}]
descriptionString可选描述文本

SpecGroup 结构(groups 数组):

字段类型说明
keyString分组唯一标识
labelString分组显示名
display_orderInteger排序
iconString图标名
fieldsArray该分组下的字段列表

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 为 yipay
  • notifier-webhook.rn — 通知插件,id 为 webhook
  • provider-incus.rn — Provider 插件,id 为 incus

插件类型自动推断规则:

文件名模式推断类型
gateway-*.rnpayment_gateway
notifier-*.rnnotifier
provider-rustbill.rnupstream_provider(特例)
provider-*.rn(非 rustbill)first_party_provider

2. 激活脚本

两种方式:

  • 重启服务器:PluginScanner 在启动时扫描 plugins/ 目录
  • 等待自动扫描:每 5 分钟后台任务自动检测文件变更

扫描后,新插件会插入 plugins 表。到 Admin UI 的插件管理页面即可看到新条目。

3. 创建接口实例

通过 Admin UI → 插件管理:

  1. 选择插件定义 → 点击“创建接口“
  2. 输入显示名称(如“主站易支付“)
  3. 系统自动从脚本提取 config_schema 并存入 plugin_interfaces.config_schema
  4. 接口创建后,在配置标签页编辑 config JSON 填入实际参数

4. 启用并使用

  1. 将接口的 enabled 设为 true
  2. 接口立即注册到 PluginRegistry,随后业务逻辑即可引用该接口
  3. 例如,在商品管理中关联某个 Provider 接口,或在通知管理中启用某个 Notifier 接口

5. 热更新脚本

  1. 在 Admin UI 插件管理页的“源码“标签页编辑脚本
  2. 保存后系统自动调用 ScriptEngine.evict(key) 清除缓存
  3. 下一次函数调用触发时自动重新编译
  4. 无需重启服务器,零停机更新

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}&notify_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"},
        },
    }
}
}

常见陷阱

函数签名

  1. 所有函数必须声明 2 个参数 (config, _unused)contracts.rsparam_count 统一为 2。引擎调用时固定传入 2 个值,写 1 个参数会导致编译失败。

  2. 函数名必须与契约完全一致。例如 create_payment 不是 createPayment。拼写错误或大小写不匹配会被编译器报告为“缺少必需函数“。

  3. 参数数量必须匹配契约。每个插件类型有固定的必需函数集(Gateway 7 个、Notifier 6 个、Provider 12/15 个),缺少任何一个都会导致编译失败。

返回值

  1. Provider 函数返回纯字符串值(不包装 Ok/Err)。Provider 适配器通过 rune::from_value::<String> 反序列化返回值。返回 Ok(xxx) 会被反序列化失败。错误用 "ERROR:描述" 前缀。

  2. 返回值使用 Rune 对象字面量 #{},不是 JSON 字符串 "{}"#{key: "val"} 是 Rune 对象,在 Rune 中是原生数据结构;"{\"key\":\"val\"}" 是字符串,类型不同。

  3. 返回复杂数据(数组、嵌套对象)时用 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 语法

  1. Rune 是静态类型。对象字面量用 #{key: value},不是 JavaScript 的 {key: value}#{} 创建的是 Rune object 类型。

  2. 字符串插值用反引号(backticks)。`text ${var} text`,不是 JavaScript 的模板字符串。

  3. 变量必须先声明后使用。与 Rust 相同,let 声明变量。Rune 不支持 var

  4. Option 值用 Some(val) / NoneSome(val) 返回一个带值的 Option,None 返回空 Option。序列化为 JSON 后分别变为 "val"null

Host API 限制

  1. Host API 函数最多 5 个参数(Rune Function trait 硬限制)。超过 5 个参数需合并为 JSON 字符串。如 http_request_with_certcert_json 参数将证书和密钥合并为一个 JSON 字符串。

  2. Host API 参数和返回值全是 String 类型(少数返回 ()i64)。不能用 i32f32 等非 String 类型传递数据。数字通过 String 传递,脚本内部按需 .parse::<f64>().parse::<i64>()

热重载与调试

  1. 热重载不生效:检查 Admin UI 是否在保存源码后触发了 evict()。保存按钮会自动处理此逻辑。手动修改数据库需要自行调用 evict。

  2. 编译错误排查:查看服务器日志(RUST_LOG=rustbill_server=debug)。编译错误信息包含具体的错误函数名和原因。

  3. 运行时错误排查:使用 rustbill_host::log_info/log_error 在脚本中加入调试日志。日志会输出到服务器日志流中。

配置和部署

  1. config_schema 必须是合法的 JSON Schema(至少含 typeproperties 键)。最小合法 schema:{"type":"object","properties":{}}

  2. plugins/ 目录路径由 config.toml[plugins].plugins_dir 配置,默认为 "./plugins"

  3. 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.rnPaymentGateway易支付聚合支付 — MD5 签名 + HTTP
plugins/gateway-banktransfer.rnPaymentGateway银行转账 — 本地逻辑 + KV 对账
plugins/notifier-webhook.rnNotifierWebhook HTTP POST 通知
plugins/notifier-email.rnNotifierSMTP 邮件通知(lettre)
plugins/provider-rustbill.rnUpstreamProviderRustBill 上游分销 — gRPC-Web
plugins/provider-incus.rnFirstPartyProviderIncus 虚拟化 — mTLS REST API

所有脚本源码均可通过 Admin UI 插件管理页面的“源码“标签页在线查看和编辑。