RustBill
基于 Rust 的财务软件,用于分销云服务器等商品。支持多租户、多供应商、多支付网关、多通知渠道。
核心能力
- 商品管理 — 动态规格、多级定价、分组分类
- 订单系统 — 创建→支付→开通→通知全生命周期
- 插件系统 — Rune 脚本热插拔,零编译部署
- 高可用 — 水平扩展 + Citus 分布式 + 熔断降级
- 双前端 — 管理后台 (SPA) + 客户前台 (shadcn/ui)
快速开始
bash <(curl -fsSL https://raw.githubusercontent.com/zyxisme/rustbill-releases/main/deploy.sh)
详见 快速部署指南。
快速部署指南
本指南帮助你在 10 分钟内将 RustBill 部署到一台全新的 Ubuntu 服务器(20.04 / 22.04 / 24.04)。
1. 二进制部署(推荐)
RustBill 提供交互式部署脚本,一行命令完成全部初始化:
bash <(curl -fsSL https://raw.githubusercontent.com/zyxisme/rustbill-releases/main/deploy.sh)
脚本会自动完成以下步骤:
- 检测系统类型和架构,下载对应的预编译二进制
- 生成默认配置文件
config.toml - 创建 systemd 服务单元
- 初始化 PostgreSQL 数据库(可选)
- 配置防火墙规则
非交互模式(CI / 自动化脚本):
bash <(curl -fsSL https://raw.githubusercontent.com/zyxisme/rustbill-releases/main/deploy.sh) -- -y
脚本执行完毕后,二进制位于 /usr/local/bin/rustbill-server,配置文件位于 /etc/rustbill/config.toml。
手动下载
如需手动安装,从 GitHub Releases 下载对应平台的 tar.gz,解压后将二进制放入 /usr/local/bin/:
# 示例:x86_64 Linux
curl -L -o rustbill.tar.gz https://github.com/zyxisme/rustbill-releases/releases/latest/download/rustbill-x86_64-unknown-linux-gnu.tar.gz
tar xzf rustbill.tar.gz
sudo mv rustbill-server rustbill-cli /usr/local/bin/
2. PostgreSQL 安装
RustBill 需要 PostgreSQL 16+。
Ubuntu 22.04 / 24.04
# 安装
sudo apt update && sudo apt install -y postgresql
# 启动并设为开机自启
sudo systemctl enable --now postgresql
创建用户和数据库
sudo -u postgres psql <<EOF
CREATE USER rustbill WITH PASSWORD 'your-db-password';
CREATE DATABASE rustbill OWNER rustbill;
GRANT ALL PRIVILEGES ON DATABASE rustbill TO rustbill;
EOF
注意:将
your-db-password替换为强密码。RustBill 启动时会自动执行数据库迁移(创建表结构),无需手动运行 SQL 文件。
3. 最小配置
RustBill 默认读取 /etc/rustbill/config.toml,并可选叠加 /etc/rustbill/config.local.toml(Figment 层叠合并)。
配置文件路径
| 路径 | 用途 |
|---|---|
/etc/rustbill/config.toml | 主配置文件 |
/etc/rustbill/config.local.toml | 本地覆盖(不纳入版本控制) |
./plugins/ | Rune 插件脚本目录 |
最小化 config.toml
创建 /etc/rustbill/config.toml,写入以下内容(仅 3 个必填项):
sudo mkdir -p /etc/rustbill /etc/rustbill/plugins
# 生成 JWT secret
JWT_SECRET=$(openssl rand -base64 64)
sudo tee /etc/rustbill/config.toml > /dev/null <<EOF
[server]
host = "127.0.0.1"
port = 50051
[db]
url = "postgres://rustbill:your-db-password@localhost/rustbill"
[jwt]
secret = "$JWT_SECRET"
[bootstrap]
admin_password = "your-admin-password"
EOF
必须修改的字段:
[db].url— 将your-db-password替换为上一步创建的数据库密码[bootstrap].admin_password— 替换为管理员初始密码(首次登录后建议修改)
完整配置参考
更多配置项(Redis、速率限制、CORS、Session 等)参见 config.example.toml:
curl -fsSL https://raw.githubusercontent.com/zyxisme/rustbill/refs/heads/main/config.example.toml
4. 启动服务
创建 systemd 服务
sudo tee /etc/systemd/system/rustbill-server.service > /dev/null <<'EOF'
[Unit]
Description=RustBill Server
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rustbill
Group=rustbill
ExecStart=/usr/local/bin/rustbill-server
WorkingDirectory=/etc/rustbill
Restart=always
RestartSec=5
Environment=RUST_LOG=rustbill_server=info
# 安全加固
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/etc/rustbill
ReadOnlyPaths=/usr/local/bin
[Install]
WantedBy=multi-user.target
EOF
创建运行用户并设置权限
sudo useradd -r -s /bin/false -d /etc/rustbill rustbill
sudo chown -R rustbill:rustbill /etc/rustbill
启动并验证
# 重载 systemd,启动服务
sudo systemctl daemon-reload
sudo systemctl enable --now rustbill-server
# 检查状态
sudo systemctl status rustbill-server
# 查看日志
sudo journalctl -u rustbill-server -f
健康检查
curl http://127.0.0.1:50051/health
# 预期输出:OK
5. Caddy 反代(推荐)
Caddy 原生支持 HTTP/3(QUIC)和自动 Let’s Encrypt 证书,是 RustBill 推荐的反向代理。
安装 Caddy
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy
最小 Caddyfile
创建 /etc/caddy/Caddyfile(将 your-domain.com 替换为你的域名):
your-domain.com {
# gRPC 后端(支持 gRPC-Web)
handle /rustbill.* {
reverse_proxy 127.0.0.1:50051
}
# 健康检查直通
handle /health {
reverse_proxy 127.0.0.1:50051
}
# Admin SPA(内嵌于 RustBill 二进制)
handle /admin* {
reverse_proxy 127.0.0.1:50051
}
# Customer 前台(独立静态文件部署)
handle {
root * /var/www/rustbill-consumer
try_files {path} /index.html
file_server
}
}
Caddy 自动申请 Let’s Encrypt 证书,启用 HTTP/3(QUIC)。唯一前提是域名 DNS 已指向服务器 IP。
重载配置
sudo systemctl reload caddy
6. 前端部署
Admin 管理后台
Admin SPA 已内嵌在 rustbill-server 二进制中,无需额外部署。
浏览器访问 https://your-domain.com/admin 即可使用。
如修改了
[server].admin_path配置项,访问路径需同步调整。
Customer 前台
Customer SPA 是独立的前端应用,需单独构建和部署:
# 从 GitHub Releases 下载预构建的静态文件
curl -L -o consumer-dist.tar.gz https://github.com/zyxisme/rustbill-releases/releases/latest/download/consumer-dist.tar.gz
sudo mkdir -p /var/www/rustbill-consumer
sudo tar xzf consumer-dist.tar.gz -C /var/www/rustbill-consumer
sudo chown -R caddy:caddy /var/www/rustbill-consumer
配置 gRPC 端点
Customer SPA 运行时从 config.json 读取后端地址。编辑 /var/www/rustbill-consumer/config.json:
{
"grpcEndpoint": "https://your-domain.com",
"appTitle": "RustBill Cloud",
"adminUrl": "https://your-domain.com/admin"
}
字段说明:
grpcEndpoint— gRPC 服务的完整 origin(含https://),Customer SPA 通过此地址发起 gRPC-Web 请求appTitle— 页面标题adminUrl— 管理后台跳转链接(前台登录页展示)
本地构建(可选)
如需自定义主题/品牌,从源码构建:
git clone https://github.com/zyxisme/rustbill.git
cd rustbill/web-consumer
npm install
cp brand.yaml.example brand.yaml # 按需编辑品牌配置
npm run build
# 产物在 dist/ 目录
7. 首次登录
- 浏览器打开
https://your-domain.com/admin - 使用
[bootstrap].admin_password中设置的用户名和密码登录- 默认用户名:
admin - 密码:配置文件中
admin_password的值
- 默认用户名:
- 登录后建议立即修改密码:
- 导航到 系统管理 → 用户管理
- 点击当前用户,修改密码
首次登录后,你可以在 Admin 后台完成以下操作:
- 添加商品 — 商品管理 → 新建商品
- 配置插件 — 基础设施 → 插件管理 → 创建接口实例
- 管理客户 — 客户管理 → 新建客户
8. 验证清单
部署完成后,逐项验证以下功能正常:
| 检查项 | 命令 / 操作 | 预期结果 |
|---|---|---|
| 服务运行 | sudo systemctl status rustbill-server | active (running) |
| 健康检查 | curl http://127.0.0.1:50051/health | OK |
| 数据库连接 | sudo journalctl -u rustbill-server | grep -i "database" | 无连接错误 |
| HTTPS 可访问 | 浏览器打开 https://your-domain.com/health | 显示 OK,证书有效 |
| Admin 登录 | 浏览器打开 https://your-domain.com/admin | 显示登录页,可成功登录 |
| Customer 前台 | 浏览器打开 https://your-domain.com | 显示产品首页,可浏览目录 |
| gRPC 通信 | Admin 后台 → Dashboard | 显示统计数据(非白屏) |
| 插件扫描 | sudo journalctl -u rustbill-server | grep -i "plugin" | 显示已扫描的插件列表 |
常见问题
服务启动失败
# 查看详细日志
sudo journalctl -u rustbill-server -n 50 --no-pager
常见原因:
- 数据库连接失败 — 检查
config.toml中[db].url是否正确,PostgreSQL 是否运行 - JWT secret 为空 —
[jwt].secret必须设置且不能为"change-me-in-production" - 插件目录不可写 — 确保
plugins/目录存在且rustbill用户有读写权限
Caddy 证书申请失败
# 查看 Caddy 日志
sudo journalctl -u caddy -n 50 --no-pager
常见原因:
- DNS 未解析 — 域名必须已指向服务器公网 IP
- 80/443 端口被占用 — 确保 Nginx/Apache 未占用这些端口
- 防火墙未开放 — 需放行 80/tcp 和 443/tcp+udp
防火墙配置
# UFW
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp # HTTP/3 QUIC
sudo ufw enable
修改 bootstrap 密码
RustBill 只在首次启动时(admin_users 表为空)使用 [bootstrap].admin_password 创建 admin 用户。如需重置:
- 登录 Admin 后台 → 用户管理 → 修改密码
- 或通过 CLI:
rustbill-cli user passwd admin --new-password "xxx"
更新 RustBill
# 重新运行部署脚本(自动下载最新版本并替换二进制)
bash <(curl -fsSL https://raw.githubusercontent.com/zyxisme/rustbill-releases/main/deploy.sh)
# 重启服务
sudo systemctl restart rustbill-server
下一步
RustBill 运维手册
RustBill 的生产部署与运维参考手册。目标读者:生产环境中的系统管理员。
1. 系统架构概览
RustBill 是基于 Rust 的云服务器分销计费与开通平台,采用 Cargo workspace 多 crate 结构:
| Crate | 职责 |
|---|---|
rustbill-proto | protobuf 定义 + tonic 代码生成(公共契约) |
rustbill-core | 领域模型 + 全部 trait(插件/存储抽象) |
rustbill-store-pg | PostgreSQL 存储实现(sqlx) |
rustbill-server | gRPC 服务端(tonic),插件加载,Session+JWT 双认证,内嵌 Admin SPA |
rustbill-cli | 最高权限 CLI 管理工具(直接 DB 访问),含 TUI 交互模式 |
rustbill-provider-* | 服务器资源供应商插件 |
rustbill-gateway-* | 支付网关插件 |
rustbill-notifier-* | 通知渠道插件 |
1.1 运行环境依赖
- Rust 1.80+(编译);运行时仅需 glibc 2.35+(ubuntu-22.04 构建产物兼容绝大多数现代 Linux 发行版)
- PostgreSQL 16+(数据存储,9 个迁移文件:
001_initial至009_plugins_table) - Redis(可选,用于 session 缓存、分布式锁、跨实例速率限制;不可用时自动降级)
- Node.js 22+ + npm(前端构建;运行时不需要)
1.2 服务端口与协议
| 端口 | 协议 | 说明 |
|---|---|---|
50051 | gRPC (HTTP/2) | tonic 服务端主端口 |
80/443 | HTTP/HTTPS | Caddy/Nginx 反向代理对外暴露(含 gRPC-Web 路径) |
/health | HTTP GET | 健康检查端点(Circuit-breaker 短路后续所有中间件层,直接返回 200 OK) |
/admin/* | HTTP GET | 内嵌 Admin SPA(通过 Tower Layer 嵌入 tonic server,SPA fallback 到 index.html) |
1.3 Tower 中间件栈
ConcurrencyLimitLayer(30) → HealthRouteLayer(/health 短路) → RateLimitLayer
→ AdminUiLayer → CorsLayer → SessionAuthLayer → JwtAuthLayer
→ ApiKeyAuthLayer → LogContextLayer → GrpcWebLayer → gRPC services
每个认证中间件只处理自己的认证类型,无凭证时直接放行(pass-through),不做 401 拦截。
2. 配置文件参考
配置文件 config.toml(复制自 config.example.toml),支持 config.local.toml 覆盖(Figment 层叠合并)。数组替换而非追加。
2.1 [server] — 服务器配置
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
host | string | "127.0.0.1" | gRPC 服务监听地址。生产环境反向代理前置时保持 127.0.0.1 |
port | integer | 50051 | gRPC 服务监听端口 |
max_concurrency | integer | 30 | 入口并发上限,超过排队(FIFO backpressure) |
notify_email | string | "" | 系统通知接收邮箱(订单通知等) |
2.2 [db] — 数据库配置
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
url | string | (必填) | PostgreSQL 连接字符串。格式:postgres://user:pass@host:5432/rustbill |
max_connections | integer | 20 | 连接池最大连接数 |
min_connections | integer | 2 | 连接池最小连接数(启动时预建立) |
idle_timeout_secs | integer | 300 | 空闲连接超时秒数 |
max_lifetime_secs | integer | 1800 | 连接最大生命秒数(到期后回收重建) |
acquire_timeout_secs | integer | 5 | 获取连接超时秒数 |
test_before_acquire | boolean | true | 取连接前执行 SELECT 1 剔除死连接 |
2.3 [jwt] — JWT 配置(Customer 认证)
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
secret | string | (必填) | JWT 签名密钥。必须 >= 32 字符。为空或为 "change-me-in-production" 时启动拒绝 |
expiry_hours | integer | 24 | access_token 过期小时数 |
2.4 [session] — Session 配置(Admin 认证)
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
cookie_name | string | "rustbill_session" | httpOnly Session Cookie 名称 |
idle_timeout_minutes | integer | 1440 | 空闲超时分钟数(24 小时) |
absolute_timeout_hours | integer | 168 | 绝对超时小时数(7 天),无论是否活跃 |
cookie_secure | boolean | false | Cookie Secure 标志(生产环境经 HTTPS 代理时设为 true) |
cookie_same_site | string | "Lax" | Cookie SameSite 策略(Strict / Lax / None) |
2.5 [bootstrap] — 初始管理员配置
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
admin_username | string | "admin" | 初始管理员用户名 |
admin_password | string | (必填) | 初始管理员密码(bcrypt 哈希存储,无默认值) |
2.6 [rate_limit] — 速率限制配置
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
enabled | boolean | true | 是否启用速率限制 |
login_per_sec | integer | 5 | 每秒登录请求上限 |
register_per_sec | integer | 1 | 每秒注册请求上限 |
2.7 [redis] — Redis 配置(可选)
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
enabled | boolean | false | 是否启用 Redis。启用后提供 Session 缓存 + 分布式锁 + 跨实例速率限制 |
url | string | "redis://127.0.0.1:6379" | Redis 连接 URL |
max_connections | integer | 10 | Redis 连接池大小 |
prefix | string | "rustbill" | Redis key 前缀(避免多实例/多环境 key 冲突) |
2.8 [plugins] — 插件配置
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
plugins_dir | string | "./plugins" | Rune 脚本插件目录。PluginScanner 扫描此目录下 *.rn 文件并同步到数据库 |
3. 构建与部署
3.1 从源码构建
# 克隆仓库
git clone https://github.com/zyxisme/rustbill.git
cd rustbill
# 编译服务端(Release,二进制约 15-25MB stripped)
cargo build --release -p rustbill-server
# 编译 CLI 管理工具
cargo build --release -p rustbill-cli
# 跨平台编译(ARM64)
cargo build --release --target aarch64-unknown-linux-gnu
3.2 构建前端
# Admin SPA(内嵌于 rustbill-server)
cd web-admin
npm install
npm run build
# 产物:dist/ → 复制到 rustbill-server/admin-dist/
# Consumer SPA(独立部署)
cd web-consumer
npm install
npm run build
# 产物:dist/ → 部署到 Web 服务器(Caddy/Nginx/Apache)
3.3 Consumer 前端配置
Consumer 运行时配置位于 web-consumer/public/config.json:
{
"grpcEndpoint": "https://your-domain.com",
"appTitle": "RustBill",
"adminUrl": "https://admin.your-domain.com"
}
| 字段 | 说明 |
|---|---|
grpcEndpoint | gRPC 服务完整 origin(跨域部署时必填,同源可省略) |
appTitle | 页面标题(浏览器标签页显示) |
adminUrl | 管理后台外部链接(前台 Dashboard 点击跳转) |
品牌配置:web-consumer/brand.yaml(品牌名/Logo/强调色/导航/集群节点),npm run build 时 vite-plugin-brand.ts 将颜色和导航编译进 JS bundle。
3.4 PostgreSQL 准备
# 创建数据库
sudo -u postgres createdb rustbill
# 创建用户
sudo -u postgres psql -c "CREATE USER rustbill WITH PASSWORD 'your-password';"
sudo -u postgres psql -c "GRANT ALL ON DATABASE rustbill TO rustbill;"
# 赋予 schema 权限(迁移由应用启动时自动执行)
sudo -u postgres psql -d rustbill -c "GRANT ALL ON SCHEMA public TO rustbill;"
迁移由应用启动时 leader 实例自动执行,无需手动 sqlx migrate run。单实例部署时该实例即为 leader。
3.5 systemd Service
# /etc/systemd/system/rustbill.service
[Unit]
Description=RustBill gRPC Server
After=network.target postgresql.service redis.service
Wants=postgresql.service
[Service]
Type=simple
User=rustbill
Group=rustbill
WorkingDirectory=/opt/rustbill
ExecStart=/opt/rustbill/rustbill-server
Restart=on-failure
RestartSec=5
# 安全加固
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/rustbill /var/log/rustbill
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
# 资源限制
MemoryHigh=512M
MemoryMax=1G
CPUQuota=200%
# 日志
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rustbill
# 环境变量
Environment="RUST_LOG=info"
Environment="RUST_LOG_FORMAT=json"
[Install]
WantedBy=multi-user.target
# 启用并启动
sudo systemctl daemon-reload
sudo systemctl enable rustbill
sudo systemctl start rustbill
sudo systemctl status rustbill
3.6 日志轮转
# /etc/logrotate.d/rustbill
/var/log/rustbill/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
copytruncate
dateext
dateformat -%Y%m%d
}
如果使用 systemd journal(StandardOutput=journal),日志由 journald 管理,无需额外 logrotate 配置。
3.7 反向代理 (Caddy)
# /etc/caddy/Caddyfile
your-domain.com {
# gRPC-Web
handle /rustbill.* {
reverse_proxy 127.0.0.1:50051 {
transport http {
versions h2c
}
}
}
# Admin 管理后台(内嵌于 rustbill-server)
handle /admin* {
reverse_proxy 127.0.0.1:50051
}
# Consumer 前台(独立部署的静态文件)
handle {
root * /var/www/rustbill-consumer
file_server
try_files {path} /index.html
}
}
3.8 Docker Compose
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: rustbill
POSTGRES_USER: rustbill
POSTGRES_PASSWORD: change-me
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redisdata:/data
ports:
- "6379:6379"
restart: unless-stopped
rustbill:
build:
context: .
dockerfile: Dockerfile
ports:
- "50051:50051"
volumes:
- ./config.toml:/opt/rustbill/config.toml:ro
- ./config.local.toml:/opt/rustbill/config.local.toml:ro
- ./plugins:/opt/rustbill/plugins
depends_on:
- postgres
- redis
restart: unless-stopped
volumes:
pgdata:
redisdata:
Dockerfile(多阶段构建):
# 构建阶段
FROM rust:1.80-bookworm AS builder
WORKDIR /app
COPY . .
RUN cargo build --release -p rustbill-server
# 运行阶段
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
WORKDIR /opt/rustbill
COPY --from=builder /app/target/release/rustbill-server .
COPY config.toml .
COPY plugins/ plugins/
EXPOSE 50051
CMD ["./rustbill-server"]
4. 数据库管理
4.1 迁移状态
# CLI 查看迁移状态
cargo run -p rustbill-cli -- db status
# 手动 psql 查看
psql -d rustbill -c "SELECT version, description, installed_on FROM _sqlx_migrations ORDER BY version;"
当前迁移清单(9 个):
| 迁移文件 | 说明 |
|---|---|
001_initial.sql | 初始基线(所有核心表、FK、CHECK 约束、索引) |
002_operation_logs.sql | 操作日志表 |
003_config_schema.sql | plugin_interfaces 新增 config_schema 列 |
004_citus_prepare.sql | Citus 兼容准备(customer_id NOT NULL) |
005_api_keys.sql | API Key 表 |
006_customer_api_key_flag.sql | customer 表 can_create_api_keys 标志 |
007_instance_detail_sections.sql | 实例详情自定义段 |
008_scriptable_plugins.sql | 脚本化插件系统迁移 |
009_plugins_table.sql | 独立 plugins 定义表 |
4.2 备份
# 完整备份(pg_dump 自定义格式)
pg_dump -Fc -d rustbill -f /backup/rustbill_$(date +%Y%m%d_%H%M%S).dump
# crontab 自动化每日备份(凌晨 2:00)
# crontab -e
0 2 * * * pg_dump -Fc -d rustbill -f /backup/rustbill_$(date +\%Y\%m\%d).dump && find /backup -name 'rustbill_*.dump' -mtime +30 -delete
4.3 恢复
# 停止服务后再恢复
sudo systemctl stop rustbill
# 恢复
pg_restore --clean --if-exists -d rustbill /backup/rustbill_20260526.dump
# 重新启动
sudo systemctl start rustbill
4.4 重建数据库
# 破坏式重建(开发/测试环境)
dropdb rustbill
createdb rustbill
# 启动服务后自动运行迁移
5. CLI 与 TUI 管理
5.1 CLI 命令
# 查看帮助
cargo run -p rustbill-cli -- --help
# 用户管理
cargo run -p rustbill-cli -- user list
cargo run -p rustbill-cli -- user create --username admin2 --password pass123 --role admin
# 插件列表
cargo run -p rustbill-cli -- plugin list
# 数据库状态
cargo run -p rustbill-cli -- db status
5.2 TUI 交互模式
cargo run -p rustbill-cli -- tui
8 个标签页:Dashboard / Admin Users / Cust Users / Customers / Products / Plugins / Instances / DB Status
| 快捷键 | 功能 |
|---|---|
q / Esc | 退出 |
Tab / Shift+Tab | 切换标签页 |
1-8 | 直接跳转到对应标签页 |
↑↓ / jk | 列表导航 |
Enter | 查看详情 |
r | 刷新数据 |
d | 删除/终止(确认弹窗) |
e / x | 启用/禁用插件 |
y / n | 确认/取消弹窗 |
Backspace | 关闭详情面板 |
TUI 完全复用服务端数据库连接,零额外连接。安全退出保证终端恢复(TerminalGuard Drop guard)。
6. 日常运维操作
所有操作均可通过 Admin UI 或 CLI 完成。以下为 CLI 常用命令及对应 Admin UI 页面。
6.1 商品管理 (Products)
Admin UI: 商品管理 → Products
- 创建商品:指定名称、描述、所属分组、动态规格(JSONB)、计费周期(monthly/quarterly/yearly)、价格
- 编辑/删除商品
- 从上游同步商品(Upstream 页 → 上游商品标签 → 勾选 → ImportUpstreamProducts)
- 批量定价(Upstream 页 → 定价管理标签 → 按加价比例批量更新售价:
cost_price × markup_ratio) - 商品规格(specs JSONB)支持动态键值对,包括 cpu_cores / memory_gb / disk_gb / bandwidth_mbps / region / os 等
6.2 客户管理 (Customers)
Admin UI: 客户管理 → Customers
- 创建客户:姓名/联系人/邮箱/电话/信用额度
- 编辑客户信息
- 余额管理:充值/扣款(
MyBalance页面可查看交易流水) - 启用 API Key 权限(
can_create_api_keys标志)
6.3 订单管理 (Orders)
Admin UI: 交易管理 → Orders
订单状态流转:
pending → paid → provisioning → active
├→ cancelled(开通失败退款)
├→ suspended
├→ refunded
└→ completed
- 创建订单:选择客户 + 商品 + 支付网关 + 计费周期
- 支付订单(余额扣减)
- 开通实例(自动由 event_worker 处理异步开通)
- 取消/退款
- 支付超时(24 小时自动取消)
6.4 实例管理 (Instances)
Admin UI: 基础设施 → Instances
实例状态:provisioning / running / stopped / suspended / terminated / error
- 启动/停止/重启/终止实例
- 查看实例详情(含插件自定义 section/iframe)
- 终止是最终操作,不可恢复
6.5 支付管理 (Payments)
Admin UI: 交易管理 → Payments
- 查看支付记录
- 银行转账人工确认
- 退款(全额/部分)
- 支付回调幂等性(SELECT FOR UPDATE 原子化)
6.6 工单管理 (Tickets)
Admin UI: 系统管理 → Tickets
工单状态:pending → processing → resolved → closed
- 创建/分配工单
- 回复(含内部备注
is_internal=true,对客户不可见) - 分配处理人变更时自动通知新旧处理人
6.7 发票管理 (Invoices)
Admin UI: 交易管理 → Billing
发票状态:draft → issued → paid / overdue / cancelled
- 手动创建发票
- 批量生成(按客户+周期)
- 标记为已付款
6.8 插件管理 (Plugins)
Admin UI: 系统管理 → Plugins
插件列表页分为两部分:
- 已安装插件(
.rn定义按 type+id 去重)- 只读列表 - 接口实例 — 可创建/启用/禁用/编辑配置 JSON/执行健康检查
当前已实现的 7 个插件:
| 类型 | 插件 ID | 说明 |
|---|---|---|
| Notifier | notifier-webhook | Webhook HTTP POST 通知 |
| Notifier | notifier-email | SMTP 邮件通知 |
| Gateway | gateway-banktransfer | 银行转账(本地逻辑) |
| Gateway | gateway-yipay | 易支付聚合支付(HTTP + MD5 签名) |
| UpstreamProvider | provider-rustbill | RustBill 上游分销(gRPC-Web) |
| FirstPartyProvider | provider-incus | Incus 虚拟化(mTLS REST API) |
插件脚本存储在数据库 plugin_interfaces.script_source 列中,通过 Admin UI 编辑。修改后由 ScriptEngine.evict() 自动热重载。
7. 日志与监控
7.1 日志格式
RustBill 使用 tracing-subscriber。
# 开发环境 — 彩色文本
RUST_LOG=rustbill_server=debug cargo run -p rustbill-server
# 生产环境 — JSON 格式(日志采集)
RUST_LOG=info RUST_LOG_FORMAT=json cargo run -p rustbill-server
# 特定模块调试
RUST_LOG=rustbill_server::plugin_scanner=debug,rustbill_server::services=info
7.2 健康检查
# 基本健康检查
curl -s http://localhost:50051/health
# 预期响应: 200 OK(空 body)
# 就绪检查(含 DB 连接池验证)
curl -s http://localhost:50051/ready
# 200 就绪,503 未就绪
7.3 关键日志事件
| 事件 | 日志级别 | 说明 |
|---|---|---|
| 服务启动 | INFO | 实例身份注册、leader 选举结果、插件扫描完成 |
| Leader 选举 | INFO | 当选/续期/释放 leader 角色 |
| 数据库迁移 | INFO | 迁移执行状态 |
| 插件扫描 | INFO / WARN | 新增/更新插件定义,文件解析异常 |
| gRPC 请求 | DEBUG | 请求路径、耗时、状态码 |
| 认证失败 | WARN | session/JWT/API key 验证失败 |
| 支付回调 | INFO | 支付网关回调处理 |
| 事件消费 | DEBUG | event_worker 处理订单事件 |
| 熔断器 | WARN | CircuitBreaker 开/半开/关状态变更 |
| Redis 降级 | WARN | Redis 不可用时自动降级通知 |
| 实例心跳 | TRACE | 每 10s 心跳更新(生产环境建议不输出) |
7.4 metrics 端点
# OpenTelemetry OTLP 导出(需要配置 OTLP exporter endpoint)
# 导出指标:gRPC 请求计数/延迟、DB 连接池状态、熔断器状态、事件队列深度
8. 升级流程
8.1 标准升级(单实例)
# 1. 拉取最新代码
cd /opt/rustbill
git pull origin main
# 2. 编译新版本
cargo build --release -p rustbill-server
# 3. 构建前端(如有变更)
cd web-admin && npm install && npm run build
cp -r dist ../rustbill-server/admin-dist/
# 4. 停止服务
sudo systemctl stop rustbill
# 5. 替换二进制
cp target/release/rustbill-server /opt/rustbill/
# 6. 启动服务
sudo systemctl start rustbill
# 7. 验证
sudo systemctl status rustbill
curl -s http://localhost:50051/health
8.2 零停机升级(多实例 + 反向代理)
# 前提:多实例部署,反向代理轮询
# 1. 逐个从负载均衡摘除实例 → 升级 → 加回
# 2. Leader 选举自动切换:关闭实例的 leader role 自动过期
# 3. 最后验证所有实例正常
8.3 数据库迁移
迁移在 leader 实例启动时自动运行。如果迁移失败,leader 持有 migration 角色锁阻止其他实例启动。
手动查看迁移状态:
psql -d rustbill -c "SELECT version, description, installed_on, success FROM _sqlx_migrations ORDER BY version;"
9. 故障排查
9.1 数据库连接失败
错误: PoolTimedOut / connection refused
- 检查 PostgreSQL 是否运行:
sudo systemctl status postgresql - 验证
pg_hba.conf允许应用用户连接:sudo -u postgres psql -c "SHOW hba_file;" - 使用
config.toml中的连接参数测试:psql "postgres://rustbill:password@localhost:5432/rustbill" -c "SELECT 1;" - 检查防火墙规则:
sudo ufw status - 连接池耗尽:增大
[db].max_connections或缩短idle_timeout_secs
9.2 端口被占用
# 查找占用端口的进程
sudo lsof -i :50051
# 或
sudo ss -tlnp | grep 50051
# 停止占用进程
sudo kill -TERM <PID>
9.3 systemd 启动失败
# 查看最近日志
sudo journalctl -u rustbill -n 50 --no-pager
# 持续跟踪
sudo journalctl -u rustbill -f
# 常见原因
# - 配置文件缺失或格式错误(config.toml)
# - JWT secret 未设置或过短(< 32 字符)
# - bootstrap admin_password 未设置
# - 数据库 URL 错误或数据库不存在
# - 端口已被占用
9.4 Admin 管理后台白屏
- 硬刷新浏览器(
Ctrl+Shift+R) - 检查
admin-dist/index.html是否存在:ls /opt/rustbill/admin-dist/ - 检查 Nginx/Caddy 配置:
/admin*路径是否正确代理到localhost:50051 - 检查浏览器控制台是否有 JS 资源 404 错误
- 重新构建并部署 admin-dist:
cd web-admin && npm run build && cp -r dist /opt/rustbill/rustbill-server/admin-dist/
9.5 插件无法加载
- 检查 Admin UI → Plugins 页面中对应接口实例的
enabled状态 - 检查
plugins/目录下.rn文件是否存在 - 检查
plugin_interfaces表的script_source列是否为空 - 检查服务器日志中
PluginScanner的 WARN/ERROR 日志 - Rune 脚本语法错误:脚本编译失败但服务器仍可正常运行,错误仅影响对应插件
9.6 HTTPS 证书问题
# Caddy 自动管理证书,确认:
# 1. DNS 解析正确:dig your-domain.com
# 2. 防火墙开放 80/443 端口:sudo ufw allow 80/tcp && sudo ufw allow 443/tcp
# 3. Caddy 日志:sudo journalctl -u caddy -f
9.7 内存使用过高
# 查看当前内存使用
sudo systemctl show rustbill -p MemoryCurrent
# 调整 systemd 资源限制
sudo systemctl edit rustbill
# 添加或修改:
# [Service]
# MemoryHigh=768M
# MemoryMax=1.5G
sudo systemctl daemon-reload
sudo systemctl restart rustbill
9.8 gRPC 调用超时
# 1. 检查服务是否可达
grpcurl -plaintext localhost:50051 list
# 2. 检查请求是否进入(日志级别调整为 debug)
RUST_LOG=rustbill_server=debug cargo run -p rustbill-server
# 3. 检查 ConcurrencyLimit 是否已达上限(默认 30)
# 增大 max_concurrency:在 config.toml [server] 节设置 max_concurrency = 100
# 4. 检查数据库连接池是否耗尽
# 增大 [db].max_connections 或检查慢查询
9.9 gRPC-Web trailers-only 错误
浏览器收到 200 OK 但 grpc-status header 非 0。这是 gRPC 错误的标准传递方式。
- 检查
grpc-statusheader 值(gRPC 状态码) - 检查
grpc-messageheader 值(错误描述) - 常见原因:认证失败(session 过期/JWT 过期)、参数校验失败、权限不足
9.10 Redis 故障降级
当 Redis 不可用时,系统自动降级:
| 功能 | Redis 正常 | Redis 降级 |
|---|---|---|
| Session 缓存 | Redis cache-aside | 纯 DB 查询 |
| 速率限制 | 跨实例共享计数 | 单实例内存计数 |
| 分布式锁 | Redis SET NX | PG advisory lock |
降级时系统正常运行但跨实例协调能力下降(速率限制不跨实例、锁竞争通过 PG 完成)。日志中会出现 WARN 级别的 Redis 连接失败信息。
10. 环境变量参考
| 变量 | 说明 | 示例 |
|---|---|---|
RUST_LOG | tracing 日志级别过滤器 | info / rustbill_server=debug / warn |
RUST_LOG_FORMAT | 日志输出格式 | 不设置=彩色文本 / json=JSON 格式 |
DATABASE_URL | PostgreSQL 连接(覆盖 config.toml) | postgres://user:pass@host/db |
CONFIG_FILE | 主配置文件路径 | /etc/rustbill/config.toml |
LOCAL_CONFIG_FILE | 本地覆盖配置路径 | /etc/rustbill/config.local.toml |
PROTOC | protoc 二进制路径(编译时需要) | /usr/local/bin/protoc |
CARGO_BUILD_JOBS | Cargo 并行编译任务数 | 1(低内存环境限制) |
11. 安全检查清单
-
config.toml中[jwt].secret已设置为 >= 32 字符的随机字符串(非"change-me-in-production") -
[bootstrap].admin_password已设置为强密码 - 生产环境
[session].cookie_secure已设为true(经 HTTPS 代理) - 生产环境
CorsLayer已配置allowed_origins白名单(非 permissive) - 防火墙仅开放必要端口(80/443,禁止 50051 直接暴露公网)
- systemd 安全加固已启用(
NoNewPrivileges=yes、ProtectSystem=strict) - 数据库用户密码不为默认值
-
config.toml和config.local.toml文件权限为600(仅 owner 可读写) - 备份 cron 任务已配置并验证
-
.claude/目录在.gitignore中(防止 Agent worktree 被提交)
12. 常见部署拓扑
12.1 最小部署(单机)
Client → Caddy (:443) → rustbill-server (:50051) → PostgreSQL (:5432)
↓ (Admin SPA 内嵌)
↓ (Consumer SPA 静态文件由 Caddy 直接 serve)
12.2 带 Redis 的中型部署
Client → Caddy (:443) → rustbill-server (:50051) → PostgreSQL (:5432)
→ Redis (:6379)
12.3 多实例高可用部署
┌→ rustbill-server-1 (:50051) ──┐
Client → Caddy LB ─┼→ rustbill-server-2 (:50051) ──┼→ PostgreSQL (Citus 多 coordinator)
└→ rustbill-server-3 (:50051) ──┘→ Redis (实例间不共享)
多实例模式下:
- Leader 选举通过 PG
leader_role表锁进行(迁移/事件消费/计费各独立角色) - 支付回调通过
SELECT FOR UPDATE原子化防重 - 事件队列通过
FOR UPDATE SKIP LOCKED并发消费 - 熔断器按 host 独立维护
- 速率限制在 Redis 启用时跨实例共享,降级后单实例独立计数
13. 插件脚本热更新
编辑插件脚本后无需重启服务:
- Admin UI → Plugins → 接口实例 → 编辑脚本源码
- 保存到数据库
plugin_interfaces.script_source ScriptEngine.evict(key)自动清除缓存- 下次调用该插件时自动重新编译并执行新版本
# 也可以手动替换 plugins/*.rn 文件
# PluginScanner 每 5 分钟后台任务检测文件变化
# 文件系统版本 > DB 版本时自动更新 plugins 表
14. 金额精度说明
所有金额使用 DECIMAL(12,2) 数据库类型,Rust 端以 rust_decimal::Decimal 承载,序列化始终保留 2 位小数。
涉及金额的 6 张表:
| 表 | 金额列 |
|---|---|
products | price |
orders | total_amount |
invoices | amount、paid_amount |
payments | amount |
customers | balance、credit_limit |
balance_transactions | amount、balance_before、balance_after |
高可用与分布式部署指南
RustBill 从设计之初就面向水平扩展和高可用部署。本文档涵盖从单机到 Citus 分布式集群的完整演进路径。
架构总览
DNS Round-Robin / VIP
│
┌──────────────────┼──────────────────┐
│ │ │
Caddy-1 Caddy-2 Caddy-N
(HTTP/3+TLS) (HTTP/3+TLS) (HTTP/3+TLS)
│ │ │
└──────┬───────────┴──────────┬───────┘
│ health-check │
│ least_conn LB │
│ │
┌──────┴──────────────────────┴───────┐
│ rustbill-server × N │
│ (stateless, tonic gRPC :50051) │
└──────┬──────────────────────┬───────┘
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ Redis │ │ OTLP │
│ (optional) │ │ Collector │
└─────────────┘ └─────────────┘
│
┌──────┴──────────────────────────────┐
│ Citus Coordinator │
│ (PG protocol routing, metadata) │
└──────┬───────────────────┬──────────┘
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ Worker-1 │ │ Worker-2 │
│(shard 1-32) │ │(shard 33-64)│
│ + replica │ │ + replica │
└─────────────┘ └─────────────┘
│ │
┌──────┴───────────────────┴──────┐
│ etcd cluster × 3 │
│ (Patroni distributed │
│ consensus for failover) │
└─────────────────────────────────┘
### 核心设计原则
| 原则 | 实现方式 |
|------|---------|
| **无状态应用层** | rustbill-server 不存本地状态,Session 入 Redis/DB |
| **PG 做共识** | 领导选举和事件外发箱全部基于 PostgreSQL 实现,无额外依赖 |
| **优雅降级** | Redis 不可用时自动回退到 PG;Rate Limit DB 模式 |
| **故障自愈** | 熔断器自动切断故障下游,恢复后半开探测 |
| **可观测性** | Request ID 全链路追踪 + OTLP 导出 + 健康检查端点 |
---
## 1. 多实例水平扩展
### 1.1 应用层无状态
rustbill-server 每个实例完全无状态:
- **Session** — Redis cache-aside 优先,miss 回源 `sessions` 表。Redis 不可用时直接查 DB。
- **JWT** — 自包含 token,任意实例可独立验证。
- **插件** — Rune 脚本源码存储于 DB `plugin_interfaces.script_source`。`ScriptEngine` 内存缓存,热重载自动同步。
- **后台任务** — 通过领导选举确保单实例执行(见第 3 节)。
所有实例共享同一份 `config.toml`,指向同一个 DB 和 Redis。无状态使得扩缩容只需增减 Caddy upstream 列表。
### 1.2 Caddy 反向代理
```caddyfile
# /etc/caddy/Caddyfile
rustbill.example.com {
# HTTP/3 QUIC
protocols h1 h2 h3
# 上游 gRPC 服务
reverse_proxy /rustbill.* {
to localhost:50051 localhost:50052
health_uri /health
health_interval 5s
lb_policy least_conn
# gRPC 需要 HTTP/2 传输
transport http {
versions h2c
}
}
# Admin 静态资源(内嵌于 server)
reverse_proxy /admin* {
to localhost:50051 localhost:50052
lb_policy least_conn
}
# 日志
log {
output file /var/log/caddy/rustbill.log
format json
}
}
关键配置项:
| 配置 | 推荐值 | 说明 |
|---|---|---|
health_uri | /health | 短路端点,仅返回 200,不做任何 DB 查询 |
health_interval | 5s | 健康检查频率。过短增加负载,过长延迟故障检测 |
lb_policy | least_conn | 最少连接数算法,适合 gRPC 长连接场景 |
transport http versions | h2c | gRPC 要求 HTTP/2 传输层 |
1.3 并发控制
[server]
max_concurrency = 30 # 每实例并发上限
每个 rustbill-server 实例在 Tower 中间件栈入口处设置 ConcurrencyLimitLayer。超过上限的请求进入 FIFO 队列等待(背压),而非直接拒绝。集群总并发容量 = N × max_concurrency。
1.4 速率限制
[rate_limit]
enabled = true
login_per_sec = 5 # 登录 5 次/秒/IP
register_per_sec = 1 # 注册 1 次/秒/IP
双后端模式:
| 后端 | 配置 | 适用场景 |
|---|---|---|
| 内存 | [redis] enabled = false | 单实例或允许独立计数 |
| Redis | [redis] enabled = true | 多实例共享计数,全局精确限流 |
Redis 不可用时自动降级到内存(per-instance 计数,非精确)。
2. 实例标识与心跳
2.1 InstanceIdentity
#![allow(unused)]
fn main() {
// 启动时生成 UUID v7(时间有序)
pub struct InstanceIdentity {
pub id: Uuid, // UUID v7
}
}
每个 rustbill-server 实例启动时生成一个 UUID v7,注册到 instance_heartbeat 表:
CREATE TABLE instance_heartbeat (
instance_id UUID PRIMARY KEY,
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
pool_idle INTEGER NOT NULL DEFAULT 0, -- 空闲连接数
pool_active INTEGER NOT NULL DEFAULT 0, -- 活跃连接数
pool_max INTEGER NOT NULL DEFAULT 0 -- 最大连接数
);
UUID v7 采用时间有序编码,便于日志排序和调试。
2.2 心跳机制
每 10 秒更新 heartbeat_at + 连接池快照(pool_idle / pool_active):
启动 → InstanceIdentity::new(注册)
→ spawn InstanceHeartbeat (interval 10s)
→ 更新 heartbeat_at + pool_{idle,active}
心跳数据被领导选举用于负载感知调度(见第 3 节)。心跳超时 30 秒的实例被视为失联,其持有的 leader 角色自动释放。
3. 领导选举
3.1 基于 PostgreSQL 表锁
RustBill 不引入 ZooKeeper/etcd 等外部共识组件,直接利用 PostgreSQL 的行锁实现领导选举。
#![allow(unused)]
fn main() {
pub struct LeaderElection {
pool: PgPool,
instance_id: Uuid,
held: Arc<RwLock<HashSet<String>>>, // 当前持有的角色
}
}
选举 SQL(try_acquire):
UPDATE leader_role
SET holder = $instance_id, heartbeat_at = NOW(), ttl_secs = 30
WHERE role = $role
AND heartbeat_at < NOW() - make_interval(secs => COALESCE(
(SELECT ttl_secs FROM leader_role WHERE role = $role), 30))
AND holder IN (
SELECT instance_id FROM instance_heartbeat
WHERE heartbeat_at > NOW() - INTERVAL '30 seconds'
ORDER BY pool_idle DESC, instance_id ASC LIMIT 1
)
关键机制:
FOR UPDATE隐式行锁 —UPDATE在 PG 中自动获取行级排他锁,多实例并发执行时只有一个成功。- 心跳过期 — TTL 30 秒。leader 不续期时租约自动过期,其他实例可抢占。
- 存活检查 — 仅心跳正常的实例(
instance_heartbeat.heartbeat_at > NOW() - 30s)可参与选举。 - 负载感知 —
ORDER BY pool_idle DESC, instance_id ASC优先选举空闲连接最多的实例。
3.2 三种 Leader 角色
| 角色 | 职责 | 由谁持有 |
|---|---|---|
migration | 数据库迁移执行 | 启动时最先选举,仅 leader 执行 sqlx::migrate! |
billing | 定时账单生成(月末批量) | 仅 leader 运行 billing cron job |
event_worker | 事件队列消费、支付超时扫描 | 仅 leader 运行 event worker loop |
三种角色独立选举,可分布在不同实例上,避免单点瓶颈。
3.3 续期与释放
#![allow(unused)]
fn main() {
// 每 10 秒续期,保持租约活跃
leader_election.spawn_renewal("event_worker".to_string(), 10);
// 实例关闭时自动释放
impl Drop for LeaderElection {
// release all held roles
}
}
续期后台任务每 10 秒更新 heartbeat_at = NOW()。若 leader 崩溃,TTL 30 秒后自动释放,follower 接管。
3.4 启动流程
create_pool → InstanceIdentity::new(注册) → LeaderElection(migration)
├── leader: run_migrations + spawn_renewal("migration")
└── follower: poll leader_role 等待 leader(最多 60s)
→ spawn InstanceHeartbeat(10s)
→ 正常启动 gRPC server
→ spawn PluginScanner
→ spawn event_worker(竞争 leader 后才开始消费)
→ spawn 其他后台任务
Follower 在迁移完成前阻塞,确保不会在旧 schema 上运行。
4. 事务外发箱与事件 Worker
4.1 事件队列表
CREATE TABLE event_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL, -- order_paid / order_provisioned / order_cancelled
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending/processing/done/failed
order_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
业务操作在同一数据库事务中写入业务数据 + event_queue 记录,保证原子性。典型流程:
BEGIN;
-- 余额扣减
UPDATE customers SET balance = balance - $amount;
-- 创建支付记录
INSERT INTO payments ...;
-- 更新订单状态
UPDATE orders SET status = 'provisioning';
-- 插入事件
INSERT INTO event_queue (event_type, payload, order_id)
VALUES ('order_paid', $payload, $order_id);
COMMIT;
4.2 Worker 消费
#![allow(unused)]
fn main() {
// 每 5 秒轮询
loop {
// 1. 检查是否仍是 event_worker leader
if !leader_election.is_leader("event_worker").await { break; }
// 2. 获取 pending 事件(并发安全)
let events = sqlx::query(
"SELECT id, event_type, payload, order_id
FROM event_queue
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED"
).fetch_all(&pool).await?;
// 3. 按 order_id 获取 advisory lock → 串行化同订单事件
for event in events {
let key = uuid_to_advisory_key(&event.order_id);
sqlx::query("SELECT pg_try_advisory_xact_lock($1)")
.bind(key).fetch_one(&pool).await?;
// dispatch: provision → notify
}
}
}
并发安全保证:
FOR UPDATE SKIP LOCKED— 多个 worker 不会重复消费同一事件pg_try_advisory_xact_lock(hash(order_id))— 同订单事件串行执行,跨订单并发执行- 事务释放时自动释放 advisory lock,无需显式解锁
4.3 事件类型与处理
| 事件 | 触发时机 | 处理逻辑 | 成功后 |
|---|---|---|---|
order_paid | 支付完成 | 调用 provider 开通实例 | 写入 order_provisioned 事件 |
order_provisioned | 开通成功 | 广播通知(客户 + 管理员) | 标记 done |
order_cancelled | 订单取消 | 退款 + 广播通知 | 标记 done |
开通失败时自动退款 + 写入 order_cancelled 事件触发通知(Saga 补偿)。
4.4 支付超时扫描
Worker 同时扫描 24 小时未支付的订单:
UPDATE orders SET status = 'cancelled'
WHERE status = 'pending'
AND created_at < NOW() - INTERVAL '24 hours'
RETURNING id
取消的订单同样写入 order_cancelled 事件,走退款+通知流程。
5. 熔断器
5.1 三段状态机
┌──────────┐ 5 次失败 ┌──────────┐
│ Closed │ ──────────────→ │ Open │
│ (正常) │ │ (熔断) │
└──────────┘ └─────┬─────┘
↑ │
│ 30s 冷却后 │
│ ┌──────────────────┐ │
└────┤ HalfOpen │←─────┘
│ (探测) │
└────┬─────────────┘
│
成功 ───┴─── 失败 → Open
- Closed — 正常状态,失败计数器递增。累计 5 次失败 → Open。
- Open — 熔断状态,直接拒绝请求(抛出
CircuitOpen错误),无需等待超时。 - HalfOpen — 30 秒冷却后进入,允许 1 次探测请求。成功 → Closed;失败 → Open。
5.2 按 host 粒度的熔断器管理
#![allow(unused)]
fn main() {
pub struct BreakerManager {
breakers: RwLock<HashMap<String, Arc<CircuitBreaker>>>,
}
}
熔断器按下游 host 隔离,一个支付网关故障不影响其他网关。用于所有出站 HTTP 调用:
- 支付网关(易支付、自定义网关)
- 上游供应商(RustBill 上游实例)
- 通知渠道(Webhook)
5.3 CoreHttpClient
#![allow(unused)]
fn main() {
pub struct CoreHttpClient {
client: reqwest::Client,
cert_clients: RwLock<HashMap<String, reqwest::Client>>, // mTLS client 缓存
breakers: BreakerManager,
}
}
统一出站 HTTP 客户端,提供:
- 3 次重试 — 指数退避,透明重试网络瞬时错误
- 熔断检查 — 请求前检查 host 熔断器状态,Open 时快速失败
- mTLS 证书缓存 — 按 cert hash 缓存
reqwest::Client实例,避免每次重建 TLS 握手
6. Redis 缓存层(可选)
Redis 是可选组件。不配置时全部功能降级运行,零阻塞。
6.1 功能矩阵
| 功能 | Redis 可用 | Redis 不可用(降级) |
|---|---|---|
| Session 缓存 | cache-aside(快) | 纯 DB 查询(慢,但可用) |
| 分布式锁 | Redis SETNX | PG advisory lock |
| 跨实例限流 | Redis 全局计数 | 内存 per-instance 计数(近似) |
6.2 配置
[redis]
enabled = true
url = "redis://localhost:6379"
max_connections = 10
prefix = "rustbill"
prefix 用于多环境隔离(如 rustbill-prod / rustbill-staging 共享同一 Redis)。
6.3 降级策略
Session 读取:
Redis GET → hit → 返回
→ miss → DB 查询 → Redis SET (异步, 忽略失败) → 返回
Rate Limit:
Redis INCR → 成功 → 全局精确
→ 失败 → 内存计数器 (per-instance)
分布式锁:
Redis SETNX → 成功 → 获取锁
→ 失败 → PG pg_try_advisory_lock
所有 Redis 操作失败后自动降级,不阻塞业务。恢复后自动切回。
7. Citus 分布式数据库
当单 PostgreSQL 实例无法承载数据量或并发时,迁移到 Citus。
7.1 分片策略
┌──────────────────┐
│ Coordinator │
│ (metadata only) │
└──┬────────────┬──┘
│ │
┌────────┴──┐ ┌────┴──────────┐
│ Worker-1 │ │ Worker-2 │
│ shard 1-32│ │ shard 33-64 │
│ (PG 16) │ │ (PG 16) │
│ + replica │ │ + replica │
└───────────┘ └───────────────┘
分片键:customer_id。按 customer_id 哈希分片,每个客户的所有关联数据落在同一 Worker。
7.2 分布式表(Distributed Tables)
按 customer_id 分片的表:
| 表名 | 分片键 | 说明 |
|---|---|---|
customers | customer_id (id) | 客户主体 |
customer_users | customer_id | 客户子账户 |
orders | customer_id | 订单 |
invoices | customer_id | 账单 |
invoice_items | customer_id | 账单明细 |
payments | customer_id | 支付记录 |
instances | customer_id | 云实例 |
balance_transactions | customer_id | 余额变动 |
tickets | customer_id | 工单 |
ticket_replies | customer_id | 工单回复 |
同客户数据共置:一个客户的所有订单、支付、实例、工单存于同一 Worker。跨 Worker Join 几乎不发生。
7.3 参考表(Reference Tables)
复制到所有 Worker 的表(全量同步,高频读取):
| 表名 | 说明 |
|---|---|
admin_users | 管理员账户 |
sessions | Session 记录 |
products | 商品定义 |
product_categories | 商品分类 |
product_groups | 商品分组 |
plugin_interfaces | 插件接口实例 |
plugins | 插件定义 |
instance_heartbeat | 实例心跳 |
leader_role | 领导选举 |
event_queue | 事件队列 |
api_keys | API 密钥 |
7.4 迁移 004 前置条件
004_citus_prepare.sql 确保所有涉及 customer_id 的列均为 NOT NULL,这是 Citus 创建分布式表的前提。此迁移与单节点 PG 完全兼容。
7.5 初始化集群
-- 在 Coordinator 上执行
-- 添加 Worker 节点
SELECT citus_add_node('worker-1.internal', 5432);
SELECT citus_add_node('worker-2.internal', 5432);
-- 创建分布式表(示例)
SELECT create_distributed_table('customers', 'id');
SELECT create_distributed_table('orders', 'customer_id');
SELECT create_distributed_table('payments', 'customer_id');
SELECT create_distributed_table('instances', 'customer_id');
-- ... 其余分布式表
-- 创建参考表
SELECT create_reference_table('admin_users');
SELECT create_reference_table('products');
SELECT create_reference_table('plugin_interfaces');
-- ... 其余参考表
推荐分片数:64(2 Worker 各 32,后续可水平扩容)。
7.6 在线扩容
-- 添加新 Worker
SELECT citus_add_node('worker-3.internal', 5432);
-- 开始数据重分布(零停机)
SELECT citus_rebalance_start();
-- 监控进度
SELECT * FROM citus_rebalance_status();
Citus 在后台迁移分片,读写正常进行。完成后分片自动重新分布到全部 Worker。
8. Worker 高可用 — Patroni + etcd
每个 Citus Worker 独立配置 PG 流复制 + Patroni 自动故障切换。
8.1 架构
┌─────────────────────────────────┐
│ etcd cluster │
│ (3 nodes, distributed concensus) │
└──┬─────────────┬─────────────┬──┘
│ │ │
┌───────┴──────┐ ┌────┴──────┐ ┌────┴──────┐
│ Worker-1 │ │ Worker-2 │ │ Worker-3 │
│ ┌───────────┐ │ │ │ │ │
│ │ Patroni │ │ │ Patroni │ │ Patroni │
│ │ (leader) │ │ │ (replica) │ │ (replica) │
│ └─────┬─────┘ │ │ │ │ │
│ │ │ │ │ │ │
│ ┌────┴────┐ │ │ ┌────────┐ │ │ ┌────────┐ │
│ │ PG 16 │ │ │ │ PG 16 │ │ │ │ PG 16 │ │
│ │ (master) │──┼─┼→│(standby)│ │ │ │(standby)│ │
│ └─────────┘ │ │ └────────┘ │ │ └────────┘ │
└───────────────┘ └────────────┘ └────────────┘
streaming streaming streaming
replication replication replication
8.2 Patroni 配置
# /etc/patroni/patroni.yml
scope: rustbill-worker1
name: worker1-node1
restapi:
listen: 0.0.0.0:8008
connect_address: worker1-node1.internal:8008
etcd:
hosts: etcd1.internal:2379,etcd2.internal:2379,etcd3.internal:2379
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576 # 1MB
postgresql:
use_pg_rewind: true
parameters:
max_connections: 200
shared_buffers: 8GB
wal_level: replica
hot_standby: "on"
max_wal_senders: 10
wal_keep_size: 1GB
postgresql:
listen: 0.0.0.0:5432
connect_address: worker1-node1.internal:5432
data_dir: /var/lib/postgresql/16/main
pg_hba:
- host replication replicator 0.0.0.0/0 md5
- host all all 0.0.0.0/0 md5
8.3 故障切换流程
1. Patroni 检测 leader 心跳丢失(etcd TTL 30s)
2. 选举新 leader(etcd CAS 操作,只有一个 replica 成功)
3. 新 leader 提升为 master(pg_ctl promote)
4. 其他 replica 切换到新 master(rewind + follow)
5. Coordinator 自动感知(连接池重连到新 master VIP)
切换时间: 通常 30-60 秒。
8.4 etcd 集群
# 三节点 etcd 集群部署
# etcd1
etcd --name etcd1 \
--initial-cluster etcd1=http://etcd1.internal:2380,etcd2=http://etcd2.internal:2380,etcd3=http://etcd3.internal:2380 \
--initial-cluster-state new
# etcd2, etcd3 同理
etcd 集群容忍 1 节点故障(3 节点 majority = 2)。
9. 监控与可观测性
9.1 健康检查端点
| 端点 | 用途 | 行为 |
|---|---|---|
GET /health | Caddy 健康检查 | 立即返回 200,短路所有中间件 |
GET /ready | 就绪检查 | 检查 DB 连接池连通性 |
# Caddy 使用 /health
curl -s http://localhost:50051/health
# → 200 OK
# k8s readiness probe 使用 /ready
curl -s http://localhost:50051/ready
# → 200 OK 或 503(DB 不通)
9.2 请求追踪
每个请求携带:
X-Request-Id— UUID v7,由服务端生成traceparent— W3C Trace Context 标准头,格式00-{trace_id}-{span_id}-01
# 示例响应头
X-Request-Id: 018f6a3c-8b7e-7d01-9a2c-c3f1e4b8d5a2
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
9.3 OpenTelemetry
[telemetry]
enabled = true
otlp_endpoint = "http://localhost:4317"
service_name = "rustbill"
导出到 OTLP Collector(Jaeger / Grafana Tempo),包含:
- gRPC 请求 span(method、status code、duration)
- DB 查询 span(sqlx 自动注入)
- 出站 HTTP span(CoreHttpClient 自动注入)
- 事件处理 span
9.4 systemd 健康检查定时器
# /etc/systemd/system/rustbill-healthcheck.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/grpcurl -plaintext localhost:50051 rustbill.identity.IdentityService/GetMe
User=rustbill
# /etc/systemd/system/rustbill-healthcheck.timer
[Timer]
OnCalendar=*:0/5 # 每 5 分钟
Persistent=true
[Install]
WantedBy=timers.target
9.5 数据库监控查询
-- 活跃 Worker 节点
SELECT node_name, node_port, is_active, should_haveshards
FROM citus_get_active_worker_nodes();
-- 分片分布
SELECT shard_count, nodename, nodeport
FROM citus_shards
GROUP BY nodename, nodeport;
-- 租户数量(每个 shard 的行数)
SELECT shardid, shard_size, result::json->0->>'row_count' AS rows
FROM citus_shard_sizes()
JOIN LATERAL json_array_elements(shard_sizes::json) AS result ON true
WHERE table_name = 'customers'::regclass;
-- 跨分片查询监控(慢查询诊断)
SELECT queryid, query, calls, mean_exec_time
FROM citus_stat_statements
WHERE calls > 0
ORDER BY mean_exec_time DESC
LIMIT 10;
9.6 资源监控
# 服务状态(内存/CPU)
systemctl status rustbill-server
# 连接数
ss -tnp | grep :50051 | wc -l
# 数据库连接
psql -c "SELECT count(*) FROM pg_stat_activity WHERE datname = 'rustbill'"
10. 故障恢复
10.1 故障场景与响应
| 场景 | 影响 | 自动恢复 | 手动操作 |
|---|---|---|---|
| 单个 server 实例宕机 | 无(Caddy 自动摘除) | Caddy health check 检测后摘除 | 重启故障实例 |
| 所有 server 实例宕机 | 服务完全中断 | 无 | 逐台重启 |
| Worker 宕机 + 有 replica | 无(Patroni 自动切换) | 30-60s 自动 failover | 修复故障节点后重新加入 |
| Worker 宕机 + 无 replica | 该 Worker 的分片不可用 | 无 | 从备份恢复 |
| Coordinator 宕机 | 所有查询中断 | 无(需手动切换) | DNS/VIP 切换到备用 Coordinator |
| Redis 宕机 | 性能下降(无中断) | 自动降级到 PG | 恢复 Redis |
| etcd 单节点宕机 | 无(集群仍可工作) | 集群自愈 | 恢复故障节点 |
| etcd 多节点宕机 | Patroni 无法选举 | 无 | 恢复 etcd 集群 |
| 下游 API 熔断 | 该下游请求快速失败 | 30s 后半开探测,成功后恢复 | 修复下游后等待自动恢复 |
10.2 Worker 故障恢复步骤
# 1. 确认 Worker 状态
psql -h coordinator -c "SELECT * FROM citus_get_active_worker_nodes();"
# 2. 如果 Worker 不可恢复(数据丢失)
psql -h coordinator <<SQL
SELECT citus_remove_node('broken-worker.internal', 5432);
SELECT citus_add_node('new-worker.internal', 5432);
SELECT citus_rebalance_start();
SQL
# 3. 监控重分布
psql -h coordinator -c "SELECT * FROM citus_rebalance_status();"
10.3 Coordinator 切换
# 方案 A: DNS 切换(推荐)
# 将 DNS A 记录从 coordinator-1 指向 coordinator-2
# TTL 设置 60s 以加速切换
# 方案 B: VIP 漂移
# keepalived 或类似工具将 VIP 从 coordinator-1 迁移到 coordinator-2
# 切换后重启所有 rustbill-server 实例(或等待连接池自动重连)
sudo systemctl restart rustbill-server
10.4 从 Citus 回退到单机 PG
# 从 Coordinator 导出全量数据
pg_dump -h coordinator.internal -U rustbill -d rustbill \
--no-owner --no-acl -F c -f rustbill_full.dump
# 导入到单机 PG
pg_restore -h single-pg.internal -U rustbill -d rustbill \
--no-owner --no-acl -j 4 rustbill_full.dump
# 更新 config.toml 的 DB URL
# 重启所有 rustbill-server 实例
11. 性能基准
测试环境:3 个 Citus Worker(8C32G),64 分片,100 万订单,50 个客户。
| 操作 | 单机 PG | Citus 3 Worker | 说明 |
|---|---|---|---|
| 客户查询自己的订单 | 8ms | 9ms | 单 shard 查询,性能接近 |
| 管理员查询全部订单 | 45ms | 62ms | 跨 shard 聚合,略有增加 |
| 创建订单 | 4ms | 5ms | 单 shard 写入 |
| 支付回调(余额扣减+建支付+更新订单) | 3ms | 3ms | 同 shard 事务 |
| Dashboard COUNT(6 表聚合) | 120ms | 180ms | 跨 shard COUNT 开销 |
| 订单列表分页(100 条/页) | 12ms | 15ms | 推送到 Coordinator 排序 |
结论: 单客户操作延迟几乎不变(+1ms)。跨客户聚合查询因 Coordinator 汇总而产生额外延迟(+30-60%),但仍在可接受范围内。
12. 部署检查清单
应用层
- Caddy 配置
health_uri /health和health_interval 5s - Caddy
lb_policy least_conn -
[jwt].secret所有实例一致 -
[db].url指向同一个 Citus Coordinator 或 PG 实例 -
[redis].enabled按需配置(多实例建议启用) -
[telemetry].enabled按需开启 OTLP
数据库层
- Citus Coordinator 已添加所有 Worker 节点
- 分布式表
customer_id列全部NOT NULL - 参考表已
create_reference_table - 每个 Worker 配置了至少一个 streaming replica
- Patroni + etcd 集群正常运行
监控
-
/health端点可被 LB 访问 -
/ready端点配置为 k8s readiness probe - OTLP exporter 成功连接到 Collector
- systemd healthcheck timer 启用
- 告警规则配置(实例宕机、Worker 宕机、熔断器 Open)
备份
- Coordinator 每日 pg_dump
- Worker WAL 归档到远程存储
- 备份恢复流程已文档化并测试
13. 运维命令速查
# === 实例管理 ===
systemctl status rustbill-server # 实例状态
systemctl restart rustbill-server # 重启(优雅关闭 + 启动)
journalctl -u rustbill-server -f # 实时日志
# === Citus 集群 ===
psql -h coordinator -c "SELECT * FROM citus_get_active_worker_nodes();"
psql -h coordinator -c "SELECT count(*) FROM citus_shards;"
psql -h coordinator -c "SELECT * FROM citus_rebalance_status();"
# === Patroni ===
patronictl -c /etc/patroni/patroni.yml list # 集群状态
patronictl -c /etc/patroni/patroni.yml switchover # 手动切换
# === 健康检查 ===
curl -s http://localhost:50051/health
grpcurl -plaintext localhost:50051 list
# === 领导选举 ===
psql -c "SELECT role, holder, heartbeat_at, ttl_secs FROM leader_role;"
# === 熔断器状态 ===
# 通过 gRPC IntegrationService 或日志查看
journalctl -u rustbill-server | grep -i "circuit"
术语对照
| 术语 | 说明 |
|---|---|
| Coordinator | Citus 协调节点,接收查询并路由到 Worker |
| Worker | Citus 工作节点,存储实际数据分片 |
| Shard / 分片 | 数据分区单元,按 customer_id 哈希分配 |
| Reference Table / 参考表 | 复制到所有 Worker 的小表 |
| Distributed Table / 分布式表 | 按分片键分布到 Worker 的大表 |
| Patroni | PostgreSQL 高可用管理工具 |
| etcd | 分布式键值存储,用于 Patroni 共识 |
| TTL | 租约有效期(Time To Live) |
| Advisory Lock | PostgreSQL 应用层锁,用于并发控制 |
| Saga | 分布式事务补偿模式 |
插件开发指南
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 插件管理页面的“源码“标签页在线查看和编辑。
gRPC API 参考
RustBill 使用 gRPC 作为后端 API 协议,同时支持 gRPC-Web(浏览器端)。共 10 个 gRPC 服务,分布在 6 个 proto package 中。
通用约定
| 约定 | 说明 |
|---|---|
| 金额 | 所有金额字段使用字符串类型(如 "50.00"),不使用浮点数 |
| ID | 所有 ID 均为 UUID v7 字符串 |
| 时间戳 | RFC3339 格式字符串(如 "2024-01-15T10:30:00Z") |
| 分页 | PageRequest { page: uint32, page_size: uint32 },响应含 PageMeta |
| 可选字段 | proto3 optional 关键字标记的字段,未设置时不出现在 JSON payload 中 |
认证机制
RustBill 使用三种认证方式,分别对应不同的用户类型和中间件:
| 认证方式 | 用户类型 | Token 格式 | 中间件 |
|---|---|---|---|
| Session Cookie | Admin | httpOnly Cookie | SessionAuthLayer |
| JWT Bearer | Customer | Authorization: Bearer <token> | JwtAuthLayer |
| API Key Bearer | Downstream | Authorization: Bearer <api_key> | ApiKeyAuthLayer |
Admin 认证流程:登录 → 服务端返回 Set-Cookie → 浏览器自动携带 cookie。Admin SPA 使用 credentials: 'include' 发送同源请求。
Customer 认证流程:登录 → 返回 access_token + refresh_token → 前端存储到 localStorage → 每次请求在 Authorization 头携带。
API Key 认证流程:在 ApiKeyService 创建 → 在 Authorization 头中作为 Bearer token 携带 → 注入 DownstreamUser。
gRPC 服务路径
所有 gRPC 方法遵循路径格式:
/rustbill.{package}.{Service}/{Method}
例如:
/rustbill.identity.IdentityService/Login/rustbill.product.ProductService/ListProducts/rustbill.integration.IntegrationService/ListPlugins
gRPC-Web
浏览器端使用 gRPC-Web 协议。请求需携带 grpc-web 头,响应可能为 trailers-only(状态码 200 + grpc-status header)。CORS 需服务端配置允许的 origin。
权限级别
| 权限级别 | 说明 |
|---|---|
| 公开 | 无需认证 |
| 已认证 | admin 或 customer 任一认证通过即可 |
| admin | 需 admin 登录(Admin 或 Operator 角色) |
| admin-only | 仅 Admin 角色(不含 Operator) |
| customer | customer 用户自动限定数据范围 |
服务清单
| 服务 | Package | 文件 | 职责 |
|---|---|---|---|
| IdentityService | rustbill.identity | identity.md | 认证、用户管理、密码管理 |
| ProductService | rustbill.product | product.md | 商品 CRUD、批量操作、上游导入 |
| CustomerService | rustbill.customer | customer.md | 客户 CRUD |
| OrderService | rustbill.order | order.md | 订单全生命周期、支付 |
| BillingService | rustbill.billing | billing.md | 发票管理、周期账单生成 |
| PaymentService | rustbill.payment | payment.md | 支付创建、回调、退款 |
| IntegrationService | rustbill.integration | integration.md | 插件管理、供应商操作、上游实例 |
| NotificationService | rustbill.notification | notification.md | 通知渠道、手动发送 |
| TicketService | rustbill.ticket | ticket.md | 工单全生命周期管理 |
| ApiKeyService | rustbill.downstream | apikey.md | API Key 创建与管理 |
公共类型
定义在 rustbill.common package(common.proto)。
PageRequest
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| page | uint32 | 1 | 页码(从 1 开始) |
| page_size | uint32 | 2 | 每页条数 |
PageMeta
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| total | uint64 | 1 | 总记录数 |
| page | uint32 | 2 | 当前页码 |
| page_size | uint32 | 3 | 每页条数 |
| total_pages | uint32 | 4 | 总页数 |
EmptyRequest / EmptyResponse
无字段的空消息。
IdRequest
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | UUID v7 格式 ID |
错误码
所有 gRPC 错误通过标准 grpc-status header 返回:
| 错误码 | gRPC Status | 说明 |
|---|---|---|
| InvalidArgument | 3 | 参数校验失败 |
| NotFound | 5 | 资源不存在 |
| AlreadyExists | 6 | 资源已存在(重复创建) |
| PermissionDenied | 7 | 权限不足 |
| Unauthenticated | 16 | 未认证(需登录) |
| Internal | 13 | 服务器内部错误 |
| Unimplemented | 12 | 功能未实现 |
| Unavailable | 14 | 服务不可用 |
IdentityService
用户认证与管理的核心服务,负责注册、登录、用户 CRUD、密码管理。支持 Admin 和 Customer 两种用户类型,认证方式分别为 Session Cookie(Admin)和 JWT Bearer(Customer)。
RPC 列表
| RPC | 描述 | 权限 |
|---|---|---|
| Register | 注册新用户(自动创建关联 Customer) | 公开 |
| SendVerificationCode | 发送邮箱验证码 | 公开 |
| Login | 登录(admin 返回 session cookie,customer 返回 JWT) | 公开 |
| RefreshToken | 刷新 access token(仅 customer) | 公开 |
| ListUsers | 用户列表(分页+筛选) | admin |
| GetUser | 获取单个用户 | admin |
| UpdateUser | 更新用户信息 | admin |
| DeleteUser | 删除用户 | admin-only |
| ChangePassword | 修改自己的密码 | 已认证 |
| AdminResetPassword | 管理员重置他人密码 | admin-only |
| GetMe | 获取当前登录用户信息 | 已认证 |
| Logout | 登出(admin 清除 session,customer 客户端删 token) | 已认证 |
Register
描述
注册新 customer 用户,自动创建关联的 Customer 记录。
Request — RegisterRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| username | string | 1 | 是 | 用户名 |
| string | 2 | 是 | 邮箱地址 | |
| display_name | string | 3 | 是 | 显示名称 |
| password | string | 4 | 是 | 登录密码 |
| verification_code | string | 5 | 是 | 邮箱验证码 |
Response — RegisterResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| user | UserInfo | 1 | 新创建的用户信息 |
| customer_id | string | 2 | 自动创建的关联 Customer ID |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 验证码无效或已过期、用户名已存在、密码不符合要求 |
| AlreadyExists | 邮箱已被注册 |
示例
grpcurl -plaintext \
-d '{"username":"testuser","email":"[email protected]","display_name":"Test","password":"Pass123!","verification_code":"123456"}' \
localhost:50051 rustbill.identity.IdentityService/Register
const resp = await api.register({
username: "testuser",
email: "[email protected]",
display_name: "Test",
password: "Pass123!",
verification_code: "123456"
});
// resp.user, resp.customer_id
SendVerificationCode
描述
向指定邮箱发送验证码,用于注册或密码重置流程。
Request — SendVerificationCodeRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| string | 1 | 是 | 接收验证码的邮箱 | |
| purpose | string | 2 | 是 | 用途:"registration" 或 "password_reset" |
Response — SendVerificationCodeResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| sent | bool | 1 | 是否发送成功 |
| message | string | 2 | 提示信息 |
| retry_after_secs | int32 | 3 | 多少秒后可重新发送 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 邮箱格式无效、发送频率过高 |
| Internal | 邮件服务不可用 |
示例
grpcurl -plaintext \
-d '{"email":"[email protected]","purpose":"registration"}' \
localhost:50051 rustbill.identity.IdentityService/SendVerificationCode
const resp = await api.sendVerificationCode({
email: "[email protected]",
purpose: "registration"
});
// resp.sent, resp.retry_after_secs
Login
描述
用户登录。user_type="admin" 时返回 session cookie(Set-Cookie 响应头),user_type="customer" 时返回 JWT token 对。
Request — LoginRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| username | string | 1 | 是 | 用户名 |
| password | string | 2 | 是 | 密码 |
| user_type | string | 3 | 是 | 用户类型:"admin" 或 "customer" |
Response — LoginResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| access_token | string | 1 | JWT access token(仅 customer) |
| refresh_token | string | 2 | JWT refresh token(仅 customer) |
| expires_in | int64 | 3 | token 过期时间(秒) |
| user | UserInfo | 4 | 当前用户信息 |
| admin_path | string | 5 | Admin 面板路径(来自服务端配置) |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 用户名或密码错误 |
| PermissionDenied | 用户已禁用 |
| NotFound | 用户不存在 |
示例
# Admin 登录
grpcurl -plaintext \
-d '{"username":"admin","password":"admin123","user_type":"admin"}' \
localhost:50051 rustbill.identity.IdentityService/Login
# Customer 登录
grpcurl -plaintext \
-d '{"username":"customer1","password":"pass123","user_type":"customer"}' \
localhost:50051 rustbill.identity.IdentityService/Login
const resp = await api.login({
username: "customer1",
password: "pass123",
user_type: "customer"
});
// resp.access_token → 存入 localStorage
// resp.user → 存入 auth store
RefreshToken
描述
使用 refresh token 获取新的 access token(仅 customer 用户)。
Request — RefreshTokenRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| refresh_token | string | 1 | 是 | 有效的 refresh token |
Response — RefreshTokenResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| access_token | string | 1 | 新的 access token |
| expires_in | int64 | 2 | 过期时间(秒) |
| refresh_token | string | 3 | 新的 refresh token(轮换) |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | refresh token 无效或已过期 |
示例
grpcurl -plaintext \
-d '{"refresh_token":"eyJhbGciOiJIUzI1NiIs..."}' \
localhost:50051 rustbill.identity.IdentityService/RefreshToken
const resp = await api.refreshToken({
refresh_token: localStorage.getItem("rustbill_customer_refresh")
});
// 更新 localStorage 中的 token
ListUsers
描述
分页查询用户列表,支持按角色、状态、用户类型、关键词、客户 ID 筛选。
Request — ListUsersRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| pagination | PageRequest | 1 | 是 | 分页参数 |
| role | string? | 2 | 否 | 角色过滤("admin" / "operator") |
| is_active | bool? | 3 | 否 | 启用状态过滤 |
| search | string? | 4 | 否 | 用户名/邮箱搜索 |
| user_type | string? | 5 | 否 | 用户类型:"admin" 或 "customer",不填默认 admin |
| customer_id | string? | 6 | 否 | 按关联客户过滤(仅 user_type=“customer” 时有效) |
Response — ListUsersResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| users | repeated UserInfo | 1 | 用户列表 |
| meta | PageMeta | 2 | 分页元数据 |
错误码
| 错误 | 说明 |
|---|---|
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"pagination":{"page":1,"page_size":20},"user_type":"customer","is_active":true}' \
localhost:50051 rustbill.identity.IdentityService/ListUsers
const resp = await api.listUsers({
pagination: { page: 1, page_size: 20 },
user_type: "customer",
is_active: true
});
GetUser
描述
获取单个用户的详细信息。
Request — GetUserRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 用户 ID(UUID) |
| user_type | string? | 2 | 否 | 用户类型提示,避免回退查询 |
Response — GetUserResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| user | UserInfo | 1 | 用户信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 用户不存在 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.identity.IdentityService/GetUser
UpdateUser
描述
更新用户信息(邮箱、显示名称、角色、状态等)。
Request — UpdateUserRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 用户 ID |
| string? | 2 | 否 | 新邮箱 | |
| display_name | string? | 3 | 否 | 新显示名称 |
| role | string? | 4 | 否 | 新角色(仅 user_type=“admin” 时有效) |
| is_active | bool? | 5 | 否 | 启用/禁用 |
| user_type | string? | 6 | 否 | 用户类型 |
| customer_id | string? | 7 | 否 | 关联客户 ID(仅 user_type=“customer” 时有效) |
Response — UpdateUserResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| user | UserInfo | 1 | 更新后的用户信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 用户不存在 |
| InvalidArgument | 参数校验失败 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-...","display_name":"New Name","is_active":true}' \
localhost:50051 rustbill.identity.IdentityService/UpdateUser
DeleteUser
描述
删除指定用户(仅 Admin 角色可操作)。
Request — DeleteUserRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 用户 ID(UUID) |
Response — DeleteUserResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 用户不存在 |
| PermissionDenied | 非 Admin 角色 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.identity.IdentityService/DeleteUser
ChangePassword
描述
当前登录用户修改自己的密码(需提供旧密码验证)。
Request — ChangePasswordRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| old_password | string | 1 | 是 | 当前密码 |
| new_password | string | 2 | 是 | 新密码 |
Response — ChangePasswordResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 旧密码错误、新密码不符合要求 |
| Unauthenticated | 未登录 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"old_password":"oldpass","new_password":"newpass123!"}' \
localhost:50051 rustbill.identity.IdentityService/ChangePassword
const resp = await api.changePassword({
old_password: "oldpass",
new_password: "newpass123!"
});
AdminResetPassword
描述
管理员重置指定用户的密码(仅 Admin 角色可操作,无需旧密码)。
Request — AdminResetPasswordRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| user_id | string | 1 | 是 | 目标用户 ID |
| new_password | string | 2 | 是 | 新密码 |
Response — AdminResetPasswordResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 用户不存在 |
| PermissionDenied | 非 Admin 角色 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"user_id":"0192a123-...","new_password":"resetpass123!"}' \
localhost:50051 rustbill.identity.IdentityService/AdminResetPassword
GetMe
描述
获取当前登录用户的个人信息。Admin 用户从 session cookie 恢复身份,Customer 用户从 JWT 解析。
Request — GetMeRequest
无字段。
Response — GetMeResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| user | UserInfo | 1 | 当前用户信息 |
错误码
| 错误 | 说明 |
|---|---|
| Unauthenticated | 未登录 |
示例
# Admin: cookie 自动携带
grpcurl -plaintext -H "cookie: rustbill_session=..." \
-d '{}' \
localhost:50051 rustbill.identity.IdentityService/GetMe
# Customer: JWT Bearer
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{}' \
localhost:50051 rustbill.identity.IdentityService/GetMe
// Admin SPA (credentials: 'include' 自动携带 cookie)
const resp = await api.getMe({});
// Customer SPA
const resp = await api.getMe({});
Logout
描述
登出当前用户。Admin 端清除 session cookie,Customer 端客户端自行删除本地 token。
Request — LogoutRequest
无字段。
Response — LogoutResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| Unauthenticated | 未登录 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{}' \
localhost:50051 rustbill.identity.IdentityService/Logout
const resp = await api.logout({});
// 清除 localStorage 中的 token
localStorage.removeItem("rustbill_customer_token");
localStorage.removeItem("rustbill_customer_refresh");
公共类型
UserInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | 用户 UUID |
| username | string | 2 | 用户名 |
| string | 3 | 邮箱地址 | |
| display_name | string | 4 | 显示名称 |
| role | string | 5 | 角色:"admin" / "operator"(admin 用户);空字符串(customer 用户) |
| is_active | bool | 6 | 是否启用 |
| created_at | string | 7 | 创建时间(RFC3339) |
| user_type | string | 8 | 用户类型:"admin" / "customer" |
| customer_id | string | 9 | 关联的 Customer ID(admin 用户为空字符串) |
ProductService
商品管理服务,负责商品的 CRUD、批量操作、上游供应商产品同步与导入、批量定价。
RPC 列表
| RPC | 描述 | 权限 |
|---|---|---|
| CreateProduct | 创建商品 | admin |
| ListProducts | 商品列表(分页+多维筛选) | 公开 |
| GetProduct | 获取商品详情 | 公开 |
| UpdateProduct | 更新商品信息 | admin |
| DeleteProduct | 删除商品 | admin |
| SyncProducts | 从 Provider 接口同步产品 | admin |
| BatchSetActive | 批量启用/禁用商品 | admin |
| BatchSetGroup | 批量设置商品分组 | admin |
| BatchDelete | 批量删除商品 | admin |
| ImportUpstreamProducts | 从上游导入商品 | admin |
| BatchUpdatePrice | 批量按比例更新价格 | admin |
CreateProduct
描述
创建一个新商品。
Request — CreateProductRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| name | string | 1 | 是 | 商品名称 |
| description | string | 2 | 是 | 商品描述 |
| price_per_month | string | 9 | 是 | 月度价格(字符串金额) |
| interface_id | string | 10 | 是 | 关联的 Provider 接口 ID(UUID) |
| specs | map<string,string> | 12 | 否 | 动态规格键值对 |
| group_id | string? | 13 | 否 | 商品分组 ID(UUID) |
| billing_cycles | map<string,string> | 14 | 否 | 多周期定价,如 {"monthly":"50.00","quarterly":"140.00"} |
| cost_price | string | 15 | 否 | 成本价(字符串金额) |
Response — CreateProductResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| product | ProductInfo | 1 | 创建的商品信息 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 参数校验失败 |
| NotFound | 指定的 interface_id 不存在 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"name":"VPS-1C2G","description":"1核2G VPS","price_per_month":"29.00","interface_id":"0192a123-...","specs":{"region":"us-west","cpu":"1","memory":"2"},"billing_cycles":{"monthly":"29.00","yearly":"290.00"}}' \
localhost:50051 rustbill.product.ProductService/CreateProduct
const resp = await api.createProduct({
name: "VPS-1C2G",
description: "1核2G VPS",
price_per_month: "29.00",
interface_id: "0192a123-...",
specs: { region: "us-west", cpu: "1", memory: "2" },
billing_cycles: { monthly: "29.00", yearly: "290.00" }
});
ListProducts
描述
分页查询商品列表,支持按接口、地域、状态、关键词、分组、分类筛选。
Request — ListProductsRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| pagination | PageRequest | 1 | 是 | 分页参数 |
| interface_id | string? | 2 | 否 | 按 Provider 接口过滤 |
| region | string? | 3 | 否 | 按地域过滤(匹配 specs.region) |
| is_active | bool? | 4 | 否 | 按启用状态过滤 |
| search | string? | 5 | 否 | 名称关键词搜索 |
| group_id | string? | 6 | 否 | 按分组过滤 |
| category_id | string? | 8 | 否 | 按分类过滤 |
Response — ListProductsResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| products | repeated ProductInfo | 1 | 商品列表 |
| meta | PageMeta | 2 | 分页元数据 |
示例
grpcurl -plaintext \
-d '{"pagination":{"page":1,"page_size":12},"region":"us-west","is_active":true}' \
localhost:50051 rustbill.product.ProductService/ListProducts
const resp = await api.listProducts({
pagination: { page: 1, page_size: 12 },
region: "us-west",
is_active: true
});
GetProduct
描述
获取单个商品详情。
Request — GetProductRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 商品 ID(UUID) |
Response — GetProductResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| product | ProductInfo | 1 | 商品信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 商品不存在 |
示例
grpcurl -plaintext \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.product.ProductService/GetProduct
UpdateProduct
描述
更新商品信息。所有字段均可选,仅更新传入的字段。
Request — UpdateProductRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 商品 ID |
| name | string? | 2 | 否 | 商品名称 |
| description | string? | 3 | 否 | 商品描述 |
| price_per_month | string? | 4 | 否 | 月度价格 |
| is_active | bool? | 5 | 否 | 启用状态 |
| specs | map<string,string> | 6 | 否 | 动态规格(完全替换) |
| group_id | string? | 7 | 否 | 商品分组 |
| billing_cycles | map<string,string> | 8 | 否 | 多周期定价(完全替换) |
| cost_price | string? | 9 | 否 | 成本价 |
Response — UpdateProductResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| product | ProductInfo | 1 | 更新后的商品信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 商品不存在 |
| InvalidArgument | 参数校验失败 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-...","price_per_month":"35.00","is_active":false}' \
localhost:50051 rustbill.product.ProductService/UpdateProduct
DeleteProduct
描述
删除指定商品。
Request — DeleteProductRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 商品 ID(UUID) |
Response — DeleteProductResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 商品不存在 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.product.ProductService/DeleteProduct
SyncProducts
描述
从指定 Provider 接口同步商品列表。调用 Provider 插件的 sync_products() 函数,返回上游商品并创建本地商品记录。
Request — SyncProductsRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | Provider 接口 ID(UUID) |
| group_id | string | 2 | 否 | 同步后归入的分组 ID |
Response — SyncProductsResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| synced | repeated ProductInfo | 1 | 同步后的商品列表 |
| count | uint32 | 2 | 同步数量 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 接口不存在或已禁用 |
| Internal | Provider 插件执行失败 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-...","group_id":"0192b456-..."}' \
localhost:50051 rustbill.product.ProductService/SyncProducts
BatchSetActive
描述
批量启用或禁用商品。
Request — BatchSetActiveRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| product_ids | repeated string | 1 | 是 | 商品 ID 列表 |
| is_active | bool | 2 | 是 | 目标启用状态 |
Response — BatchSetActiveResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| updated_count | uint32 | 1 | 实际更新的商品数量 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | product_ids 为空 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"product_ids":["id1","id2","id3"],"is_active":true}' \
localhost:50051 rustbill.product.ProductService/BatchSetActive
BatchSetGroup
描述
批量将商品归入指定分组。group_id 为空字符串时取消分组。
Request — BatchSetGroupRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| product_ids | repeated string | 1 | 是 | 商品 ID 列表 |
| group_id | string | 2 | 是 | 目标分组 ID(空字符串=取消分组) |
Response — BatchSetGroupResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| updated_count | uint32 | 1 | 实际更新的商品数量 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | product_ids 为空 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"product_ids":["id1","id2"],"group_id":"0192b456-..."}' \
localhost:50051 rustbill.product.ProductService/BatchSetGroup
BatchDelete
描述
批量删除商品。
Request — BatchDeleteRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| product_ids | repeated string | 1 | 是 | 商品 ID 列表 |
Response — BatchDeleteResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| deleted_count | uint32 | 1 | 实际删除的商品数量 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | product_ids 为空 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"product_ids":["id1","id2"]}' \
localhost:50051 rustbill.product.ProductService/BatchDelete
ImportUpstreamProducts
描述
将上游商品批量导入本地商品表。配合 IntegrationService/SyncProducts 从上游拉取数据后批量导入。
Request — ImportUpstreamProductsRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | Provider 接口 ID |
| items | repeated UpstreamProductItem | 2 | 是 | 待导入的商品列表 |
| group_id | string | 3 | 否 | 导入后归入的分组 ID |
UpstreamProductItem
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| name | string | 1 | 商品名称 |
| description | string | 2 | 商品描述 |
| specs | map<string,string> | 3 | 规格键值对 |
| cost_price | string | 4 | 成本价 |
Response — ImportUpstreamProductsResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| imported_count | uint32 | 1 | 成功导入数量 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | interface_id 无效或 items 为空 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-...","items":[{"name":"VPS-1C1G","description":"1核1G","specs":{"cpu":"1"},"cost_price":"5.00"}],"group_id":"0192b456-..."}' \
localhost:50051 rustbill.product.ProductService/ImportUpstreamProducts
BatchUpdatePrice
描述
批量按加价比例更新商品售价。新售价 = 成本价 × markup_ratio。
Request — BatchUpdatePriceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| product_ids | repeated string | 1 | 是 | 商品 ID 列表 |
| markup_ratio | string | 2 | 是 | 加价比例,如 "1.3" 表示 130%(即加价 30%) |
Response — BatchUpdatePriceResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| updated_count | uint32 | 1 | 实际更新的商品数量 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | markup_ratio 格式无效或 product_ids 为空 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"product_ids":["id1","id2"],"markup_ratio":"1.3"}' \
localhost:50051 rustbill.product.ProductService/BatchUpdatePrice
公共类型
ProductInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | 商品 UUID |
| name | string | 2 | 商品名称 |
| description | string | 3 | 商品描述 |
| price_per_month | string | 10 | 月度价格(字符串金额) |
| interface_id | string | 11 | 关联的 Provider 接口 ID |
| is_active | bool | 13 | 是否启用 |
| created_at | string | 14 | 创建时间(RFC3339) |
| updated_at | string | 15 | 更新时间(RFC3339) |
| specs | map<string,string> | 16 | 动态规格键值对 |
| group_id | string | 17 | 商品分组 ID |
| billing_cycles | map<string,string> | 18 | 多周期定价映射 |
| cost_price | string | 19 | 成本价(字符串金额) |
SpecField
定义商品规格模板中的字段,由 Provider 插件提供。
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| key | string | 1 | 字段标识符 |
| label | string | 2 | 显示标签 |
| field_type | string | 3 | 字段类型("text" / "number" / "select" / "radio") |
| required | bool | 4 | 是否必填 |
| display_order | uint32 | 5 | 显示顺序 |
| options | repeated SpecOption | 6 | 选项列表(select/radio 类型) |
| min | int64? | 7 | 最小值(number 类型) |
| max | int64? | 8 | 最大值(number 类型) |
| default_value | string? | 9 | 默认值 |
| unit | string | 10 | 单位(如 "GB", "Mbps") |
| description | string | 11 | 字段描述 |
| icon | string | 12 | 图标名称 |
| group | string | 13 | 所属分组 key |
| step | int64? | 14 | 步长(number 类型) |
| price_impact | PriceImpact | 15 | 价格影响配置 |
SpecOption
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| value | string | 1 | 选项值 |
| label | string | 2 | 显示标签 |
| icon | string | 3 | 图标名称 |
| description | string | 4 | 选项描述 |
| price_modifier | string | 5 | 价格修正值 |
| disabled | bool | 6 | 是否禁用 |
SpecGroup
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| key | string | 1 | 分组标识符 |
| label | string | 2 | 显示标签 |
| icon | string | 3 | 图标名称 |
| display_order | uint32 | 4 | 显示顺序 |
| fields | repeated SpecField | 5 | 包含的字段列表 |
PriceImpact
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| mode | string | 1 | 计价模式("add" / "multiply") |
| amount | string | 2 | 价格影响金额 |
| currency | string | 3 | 货币代码 |
CustomerService
客户管理服务,负责客户的 CRUD 操作。Customer 用户登录后自动限定数据范围为自身的 customer_id。
RPC 列表
| RPC | 描述 | 权限 |
|---|---|---|
| CreateCustomer | 创建客户 | admin |
| ListCustomers | 客户列表(分页+筛选) | admin |
| GetCustomer | 获取客户详情 | admin |
| UpdateCustomer | 更新客户信息 | admin |
| DeleteCustomer | 删除客户 | admin |
CreateCustomer
描述
创建新客户。
Request — CreateCustomerRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| name | string | 1 | 是 | 客户名称 |
| contact_person | string | 2 | 是 | 联系人 |
| string | 3 | 是 | 联系邮箱 | |
| phone | string | 4 | 是 | 联系电话 |
| company | string | 5 | 是 | 公司名称 |
| notes | string | 6 | 是 | 备注 |
| credit_limit | string | 7 | 是 | 信用额度(字符串金额) |
Response — CreateCustomerResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| customer | CustomerInfo | 1 | 创建的客户信息 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 参数校验失败 |
| AlreadyExists | 同名称客户已存在 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"name":"ACME Corp","contact_person":"John Doe","email":"[email protected]","phone":"1234567890","company":"ACME Inc.","notes":"VIP客户","credit_limit":"10000.00"}' \
localhost:50051 rustbill.customer.CustomerService/CreateCustomer
const resp = await api.createCustomer({
name: "ACME Corp",
contact_person: "John Doe",
email: "[email protected]",
phone: "1234567890",
company: "ACME Inc.",
notes: "VIP客户",
credit_limit: "10000.00"
});
ListCustomers
描述
分页查询客户列表,支持按状态和关键词筛选。
Request — ListCustomersRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| pagination | PageRequest | 1 | 是 | 分页参数 |
| is_active | bool? | 2 | 否 | 按启用状态过滤 |
| search | string? | 3 | 否 | 名称/邮箱关键词搜索 |
Response — ListCustomersResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| customers | repeated CustomerInfo | 1 | 客户列表 |
| meta | PageMeta | 2 | 分页元数据 |
错误码
| 错误 | 说明 |
|---|---|
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"pagination":{"page":1,"page_size":20},"is_active":true,"search":"ACME"}' \
localhost:50051 rustbill.customer.CustomerService/ListCustomers
GetCustomer
描述
获取单个客户详情。
Request — GetCustomerRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 客户 ID(UUID) |
Response — GetCustomerResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| customer | CustomerInfo | 1 | 客户信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 客户不存在 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.customer.CustomerService/GetCustomer
UpdateCustomer
描述
更新客户信息。所有字段均可选,仅更新传入的字段。
Request — UpdateCustomerRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 客户 ID |
| name | string? | 2 | 否 | 客户名称 |
| contact_person | string? | 3 | 否 | 联系人 |
| string? | 4 | 否 | 联系邮箱 | |
| phone | string? | 5 | 否 | 联系电话 |
| company | string? | 6 | 否 | 公司名称 |
| notes | string? | 7 | 否 | 备注 |
| credit_limit | string? | 8 | 否 | 信用额度 |
| is_active | bool? | 9 | 否 | 启用状态 |
| can_create_api_keys | bool? | 10 | 否 | 是否允许创建 API Key |
Response — UpdateCustomerResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| customer | CustomerInfo | 1 | 更新后的客户信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 客户不存在 |
| InvalidArgument | 参数校验失败 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-...","credit_limit":"20000.00","can_create_api_keys":true}' \
localhost:50051 rustbill.customer.CustomerService/UpdateCustomer
DeleteCustomer
描述
删除指定客户。
Request — DeleteCustomerRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 客户 ID(UUID) |
Response — DeleteCustomerResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 客户不存在 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.customer.CustomerService/DeleteCustomer
公共类型
CustomerInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | 客户 UUID |
| name | string | 2 | 客户名称 |
| contact_person | string | 3 | 联系人姓名 |
| string | 4 | 联系邮箱 | |
| phone | string | 5 | 联系电话 |
| company | string | 6 | 公司名称 |
| notes | string | 7 | 备注 |
| balance | string | 8 | 账户余额(字符串金额) |
| credit_limit | string | 9 | 信用额度(字符串金额) |
| is_active | bool | 10 | 是否启用 |
| created_at | string | 11 | 创建时间(RFC3339) |
| updated_at | string | 12 | 更新时间(RFC3339) |
| can_create_api_keys | bool | 13 | 是否允许创建 API Key |
OrderService
订单全生命周期管理服务,涵盖下单、支付、查询和状态管理。订单状态从 pending 开始流转,支付成功后异步触发资源开通。
RPC 列表
| RPC | 描述 | 权限 |
|---|---|---|
| CreateOrder | 创建订单(可选同步返回支付信息) | 已认证(customer) |
| ListOrders | 订单列表(分页+筛选) | admin 全量 / customer 自身 |
| GetOrder | 获取订单详情 | admin 全量 / customer 自身 |
| UpdateOrder | 更新订单状态/备注 | admin |
| DeleteOrder | 删除订单 | admin |
| PayOrder | 发起支付 | 已认证(customer) |
CreateOrder
描述
创建新订单,可选择是否在创建时同时发起支付。
Request — CreateOrderRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| customer_id | string | 1 | 是 | 客户 ID(UUID) |
| product_id | string | 2 | 是 | 商品 ID(UUID) |
| provider_interface_id | string | 3 | 是 | Provider 接口 ID(UUID) |
| server_spec | ServerSpec | 4 | 是 | 服务器规格配置 |
| currency | string | 5 | 是 | 货币代码(如 "CNY") |
| gateway_interface_id | string | 6 | 是 | 支付网关接口 ID(UUID) |
| notes | string | 7 | 是 | 备注 |
| billing_cycle | string | 8 | 是 | 计费周期:"monthly" / "quarterly" / "yearly" |
Response — CreateOrderResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| order | OrderInfo | 1 | 创建的订单信息 |
| payment | PaymentInfo? | 2 | 支付信息(如果创建时请求支付) |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 参数校验失败(商品不存在、接口无效等) |
| NotFound | 商品、接口或客户不存在 |
| Unauthenticated | 未登录 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"customer_id":"0192a123-...","product_id":"0192b456-...","provider_interface_id":"0192c789-...","server_spec":{"cpu_cores":2,"memory_gb":4,"disk_gb":50,"bandwidth_mbps":100,"region":"us-west","os":"ubuntu-22.04"},"currency":"CNY","gateway_interface_id":"0192d012-...","notes":"","billing_cycle":"monthly"}' \
localhost:50051 rustbill.order.OrderService/CreateOrder
const resp = await api.createOrder({
customer_id: "0192a123-...",
product_id: "0192b456-...",
provider_interface_id: "0192c789-...",
server_spec: {
cpu_cores: 2,
memory_gb: 4,
disk_gb: 50,
bandwidth_mbps: 100,
region: "us-west",
os: "ubuntu-22.04"
},
currency: "CNY",
gateway_interface_id: "0192d012-...",
notes: "",
billing_cycle: "monthly"
});
ListOrders
描述
分页查询订单列表。Admin 用户可查看所有订单,Customer 用户自动限定为自身订单。
Request — ListOrdersRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| pagination | PageRequest | 1 | 是 | 分页参数 |
| customer_id | string? | 2 | 否 | 按客户过滤(admin 可用) |
| status | string? | 3 | 否 | 按状态过滤 |
| provider_interface_id | string? | 4 | 否 | 按 Provider 接口过滤 |
| search | string? | 5 | 否 | 关键词搜索 |
Response — ListOrdersResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| orders | repeated OrderInfo | 1 | 订单列表 |
| meta | PageMeta | 2 | 分页元数据 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"pagination":{"page":1,"page_size":20},"status":"active"}' \
localhost:50051 rustbill.order.OrderService/ListOrders
GetOrder
描述
获取单个订单详情。
Request — GetOrderRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 订单 ID(UUID) |
Response — GetOrderResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| order | OrderInfo | 1 | 订单信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 订单不存在 |
| PermissionDenied | customer 用户无权查看他人订单 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.order.OrderService/GetOrder
UpdateOrder
描述
更新订单状态、Provider 实例 ID 或备注。
Request — UpdateOrderRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 订单 ID |
| status | string? | 2 | 否 | 新状态 |
| provider_instance_id | string? | 3 | 否 | 上游实例 ID |
| notes | string? | 4 | 否 | 备注 |
Response — UpdateOrderResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| order | OrderInfo | 1 | 更新后的订单信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 订单不存在 |
| InvalidArgument | 状态流转不合法 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-...","status":"active","notes":"已开通"}' \
localhost:50051 rustbill.order.OrderService/UpdateOrder
DeleteOrder
描述
删除指定订单。
Request — DeleteOrderRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 订单 ID(UUID) |
Response — DeleteOrderResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 订单不存在 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.order.OrderService/DeleteOrder
PayOrder
描述
对指定订单发起支付。调用 PaymentGateway 插件生成支付链接或二维码。支付成功后 event_worker 异步触发资源开通。
Request — PayOrderRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| order_id | string | 1 | 是 | 订单 ID(UUID) |
| gateway_interface_id | string | 2 | 是 | 支付网关接口 ID(UUID) |
Response — PayOrderResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| payment | PaymentInfo | 1 | 支付信息 |
PaymentInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| payment_id | string | 1 | 支付记录 ID |
| gateway_interface_id | string | 2 | 网关接口 ID |
| payment_url | string | 3 | 支付页面 URL |
| qr_code | string | 4 | 二维码内容(Base64 或 URL) |
| instructions | string | 5 | 支付说明 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 订单不存在 |
| InvalidArgument | 订单状态不允许支付(如已支付或已取消) |
| Internal | 网关插件执行失败 |
| Unauthenticated | 未登录 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"order_id":"0192a123-...","gateway_interface_id":"0192d012-..."}' \
localhost:50051 rustbill.order.OrderService/PayOrder
const resp = await api.payOrder({
order_id: "0192a123-...",
gateway_interface_id: "0192d012-..."
});
// resp.payment.payment_url → 跳转到支付页面
公共类型
OrderInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | 订单 UUID |
| customer_id | string | 2 | 客户 ID |
| product_id | string | 3 | 商品 ID |
| provider_interface_id | string | 4 | Provider 接口 ID |
| provider_instance_id | string | 5 | 上游实例 ID(开通后填充) |
| server_spec | ServerSpec | 6 | 服务器规格 |
| status | string | 7 | 订单状态 |
| amount | string | 8 | 订单金额(字符串) |
| currency | string | 9 | 货币代码 |
| gateway_interface_id | string | 10 | 支付网关接口 ID |
| gateway_payment_id | string | 11 | 网关支付 ID(支付后填充) |
| notes | string | 12 | 备注 |
| created_at | string | 13 | 创建时间(RFC3339) |
| updated_at | string | 14 | 更新时间(RFC3339) |
| product_name | string | 15 | 商品名称(冗余) |
| billing_cycle | string | 16 | 计费周期:"monthly" / "quarterly" / "yearly" |
ServerSpec
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| cpu_cores | uint32 | 1 | CPU 核心数 |
| memory_gb | uint32 | 2 | 内存(GB) |
| disk_gb | uint32 | 3 | 磁盘(GB) |
| bandwidth_mbps | uint32 | 4 | 带宽(Mbps) |
| region | string | 5 | 地域 |
| os | string | 6 | 操作系统 |
| extra_specs | map<string,string> | 7 | 扩展规格键值对 |
订单状态流转
pending → paid → provisioning → active / suspended / cancelled / refunded / completed
↓
terminated
| 状态 | 说明 |
|---|---|
| pending | 待支付 |
| paid | 已支付(等待开通) |
| provisioning | 开通中 |
| active | 运行中 |
| suspended | 已暂停 |
| cancelled | 已取消 |
| refunded | 已退款 |
| completed | 已完成 |
BillingService
账单管理服务,负责发票的 CRUD 操作和周期性账单生成。支持按计费周期自动汇总订单生成客户账单。
RPC 列表
| RPC | 描述 | 权限 |
|---|---|---|
| CreateInvoice | 手动创建发票 | admin |
| ListInvoices | 发票列表(分页+筛选) | admin 全量 / customer 自身 |
| GetInvoice | 获取发票详情 | admin 全量 / customer 自身 |
| UpdateInvoice | 更新发票状态/备注 | admin |
| DeleteInvoice | 删除发票 | admin |
| GenerateInvoices | 批量生成周期账单 | admin |
CreateInvoice
描述
手动创建一张发票(含发票行项目)。
Request — CreateInvoiceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| customer_id | string | 1 | 是 | 客户 ID(UUID) |
| billing_period_start | string | 2 | 是 | 计费周期开始(日期字符串) |
| billing_period_end | string | 3 | 是 | 计费周期结束(日期字符串) |
| amount | string | 4 | 是 | 小计金额(字符串) |
| tax_amount | string | 5 | 是 | 税额(字符串) |
| total_amount | string | 6 | 是 | 总金额(含税) |
| due_date | string | 7 | 是 | 到期日期 |
| notes | string | 8 | 是 | 备注 |
| items | repeated InvoiceItemInput | 9 | 是 | 发票行项目列表 |
InvoiceItemInput
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| description | string | 1 | 项目描述 |
| quantity | uint32 | 2 | 数量 |
| unit_price | string | 3 | 单价(字符串金额) |
| amount | string | 4 | 金额(quantity × unit_price) |
Response — CreateInvoiceResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| invoice | InvoiceInfo | 1 | 创建的发票信息 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 参数校验失败 |
| NotFound | 客户不存在 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"customer_id":"0192a123-...","billing_period_start":"2024-01-01","billing_period_end":"2024-01-31","amount":"100.00","tax_amount":"13.00","total_amount":"113.00","due_date":"2024-02-15","notes":"1月账单","items":[{"description":"VPS-1C2G 月费","quantity":1,"unit_price":"100.00","amount":"100.00"}]}' \
localhost:50051 rustbill.billing.BillingService/CreateInvoice
const resp = await api.createInvoice({
customer_id: "0192a123-...",
billing_period_start: "2024-01-01",
billing_period_end: "2024-01-31",
amount: "100.00",
tax_amount: "13.00",
total_amount: "113.00",
due_date: "2024-02-15",
notes: "1月账单",
items: [{ description: "VPS-1C2G 月费", quantity: 1, unit_price: "100.00", amount: "100.00" }]
});
ListInvoices
描述
分页查询发票列表。Admin 可查看所有发票,Customer 自动限定为自身发票。
Request — ListInvoicesRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| pagination | PageRequest | 1 | 是 | 分页参数 |
| customer_id | string? | 2 | 否 | 按客户过滤(admin 可用) |
| status | string? | 3 | 否 | 按状态过滤 |
Response — ListInvoicesResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| invoices | repeated InvoiceInfo | 1 | 发票列表 |
| meta | PageMeta | 2 | 分页元数据 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"pagination":{"page":1,"page_size":20},"status":"issued"}' \
localhost:50051 rustbill.billing.BillingService/ListInvoices
GetInvoice
描述
获取单张发票详情,含发票行项目。
Request — GetInvoiceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 发票 ID(UUID) |
Response — GetInvoiceResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| invoice | InvoiceInfo | 1 | 发票信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 发票不存在 |
| PermissionDenied | customer 用户无权查看他人发票 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.billing.BillingService/GetInvoice
UpdateInvoice
描述
更新发票状态或备注。
Request — UpdateInvoiceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 发票 ID |
| status | string? | 2 | 否 | 新状态 |
| notes | string? | 3 | 否 | 备注 |
Response — UpdateInvoiceResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| invoice | InvoiceInfo | 1 | 更新后的发票信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 发票不存在 |
| InvalidArgument | 状态流转不合法 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-...","status":"paid"}' \
localhost:50051 rustbill.billing.BillingService/UpdateInvoice
DeleteInvoice
描述
删除指定发票。
Request — DeleteInvoiceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 发票 ID(UUID) |
Response — DeleteInvoiceResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 发票不存在 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.billing.BillingService/DeleteInvoice
GenerateInvoices
描述
按指定计费周期批量生成客户发票。系统遍历周期内所有已完成或活跃的订单,按客户汇总生成发票。
Request — GenerateInvoicesRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| billing_period_start | string | 1 | 是 | 计费周期开始(日期字符串) |
| billing_period_end | string | 2 | 是 | 计费周期结束(日期字符串) |
Response — GenerateInvoicesResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| generated_count | uint32 | 1 | 生成的发票数量 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 日期格式无效或周期非法 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"billing_period_start":"2024-01-01","billing_period_end":"2024-01-31"}' \
localhost:50051 rustbill.billing.BillingService/GenerateInvoices
公共类型
InvoiceInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | 发票 UUID |
| invoice_number | string | 2 | 发票编号 |
| customer_id | string | 3 | 客户 ID |
| billing_period_start | string | 4 | 计费周期开始 |
| billing_period_end | string | 5 | 计费周期结束 |
| amount | string | 6 | 小计金额(字符串) |
| tax_amount | string | 7 | 税额(字符串) |
| total_amount | string | 8 | 总金额(含税,字符串) |
| status | string | 9 | 发票状态 |
| issued_at | string | 10 | 签发时间(RFC3339) |
| paid_at | string | 11 | 支付时间(RFC3339) |
| due_date | string | 12 | 到期日期 |
| notes | string | 13 | 备注 |
| created_at | string | 14 | 创建时间(RFC3339) |
| updated_at | string | 15 | 更新时间(RFC3339) |
| items | repeated InvoiceItemInfo | 16 | 发票行项目 |
InvoiceItemInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | 行项目 UUID |
| description | string | 2 | 项目描述 |
| quantity | uint32 | 3 | 数量 |
| unit_price | string | 4 | 单价(字符串金额) |
| amount | string | 5 | 金额(quantity × unit_price) |
发票状态流转
draft → issued → paid / overdue → cancelled
| 状态 | 说明 |
|---|---|
| draft | 草稿 |
| issued | 已签发(待支付) |
| paid | 已支付 |
| overdue | 已逾期 |
| cancelled | 已取消 |
PaymentService
支付管理服务,负责支付创建、状态查询、网关回调处理和退款操作。支持对接多种 PaymentGateway 插件(如易支付、银行转账)。
RPC 列表
| RPC | 描述 | 权限 |
|---|---|---|
| CreatePayment | 创建支付记录(生成支付链接) | 已认证 |
| ListPayments | 支付列表(分页+筛选) | admin 全量 / customer 自身 |
| GetPayment | 获取支付详情 | admin 全量 / customer 自身 |
| QueryPaymentStatus | 向网关查询支付状态 | admin |
| HandleCallback | 接收支付网关异步回调 | 公开(网关调用) |
| RefundPayment | 发起退款 | admin |
CreatePayment
描述
为订单或发票创建支付记录。调用 PaymentGateway 插件生成支付 URL 或二维码。
Request — CreatePaymentRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| order_id | string | 1 | 是 | 订单 ID(UUID) |
| invoice_id | string | 2 | 是 | 发票 ID(UUID) |
| gateway_interface_id | string | 3 | 是 | 支付网关接口 ID(UUID) |
| return_url | string | 4 | 是 | 支付完成后跳转 URL |
Response — CreatePaymentResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| payment | PaymentRecord | 1 | 支付记录 |
| payment_url | string | 2 | 支付页面 URL |
| qr_code | string | 3 | 二维码内容(Base64 或 URL) |
| instructions | string | 4 | 支付说明 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 订单/发票不存在或已支付 |
| Internal | 网关插件执行失败 |
| Unauthenticated | 未登录 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"order_id":"0192a123-...","invoice_id":"","gateway_interface_id":"0192d012-...","return_url":"https://example.com/payment/result"}' \
localhost:50051 rustbill.payment.PaymentService/CreatePayment
const resp = await api.createPayment({
order_id: "0192a123-...",
invoice_id: "",
gateway_interface_id: "0192d012-...",
return_url: "https://example.com/payment/result"
});
// 跳转到 resp.payment_url
ListPayments
描述
分页查询支付记录列表。Admin 可查看所有支付,Customer 自动限定为自身支付。
Request — ListPaymentsRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| pagination | PageRequest | 1 | 是 | 分页参数 |
| order_id | string? | 2 | 否 | 按订单过滤 |
| invoice_id | string? | 3 | 否 | 按发票过滤 |
| gateway_interface_id | string? | 4 | 否 | 按网关接口过滤 |
| status | string? | 5 | 否 | 按状态过滤 |
Response — ListPaymentsResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| payments | repeated PaymentRecord | 1 | 支付列表 |
| meta | PageMeta | 2 | 分页元数据 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"pagination":{"page":1,"page_size":20},"status":"success"}' \
localhost:50051 rustbill.payment.PaymentService/ListPayments
GetPayment
描述
获取单条支付记录详情。
Request — GetPaymentRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 支付记录 ID(UUID) |
Response — GetPaymentResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| payment | PaymentRecord | 1 | 支付记录 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 支付记录不存在 |
| PermissionDenied | customer 用户无权查看他人支付 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.payment.PaymentService/GetPayment
QueryPaymentStatus
描述
主动向支付网关查询支付状态并同步本地记录。
Request — QueryPaymentStatusRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| payment_id | string | 1 | 是 | 支付记录 ID(UUID) |
Response — QueryPaymentStatusResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| payment | PaymentRecord | 1 | 更新后的支付记录 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 支付记录不存在 |
| Internal | 网关查询失败 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"payment_id":"0192a123-..."}' \
localhost:50051 rustbill.payment.PaymentService/QueryPaymentStatus
HandleCallback
描述
接收支付网关的异步回调通知。使用乐观锁(expected_status)确保回调幂等,防止重复处理。
注意:此 RPC 由支付网关调用,不由前端直接调用。
Request — PaymentCallbackRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| gateway_interface_id | string | 1 | 是 | 网关接口 ID(UUID) |
| payload | string | 2 | 是 | 网关回调原始 payload(由网关插件自行解析) |
Response — PaymentCallbackResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| payment | PaymentRecord | 1 | 更新后的支付记录 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 签名验证失败、订单号不匹配 |
| NotFound | 支付记录不存在 |
| AlreadyExists | 重复回调(乐观锁拦截) |
回调流程
支付网关 → POST /rustbill.payment.PaymentService/HandleCallback
→ gateway 插件验证签名
→ 更新 payment.status = "success"
→ 更新 order.status = "paid"
→ INSERT event_queue(order_paid) ← event_worker 异步处理开通
RefundPayment
描述
对已支付订单发起退款。amount 为空时全额退款。
Request — RefundPaymentRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| payment_id | string | 1 | 是 | 支付记录 ID(UUID) |
| amount | string | 2 | 是 | 退款金额(空字符串=全额退款) |
Response — RefundPaymentResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| payment | PaymentRecord | 1 | 更新后的支付记录 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 支付记录不存在 |
| InvalidArgument | 退款金额超过支付金额、支付状态不允许退款 |
| Internal | 网关退款执行失败 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"payment_id":"0192a123-...","amount":""}' \
localhost:50051 rustbill.payment.PaymentService/RefundPayment
公共类型
PaymentRecord
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | 支付记录 UUID |
| order_id | string | 2 | 关联订单 ID |
| invoice_id | string | 3 | 关联发票 ID |
| amount | string | 4 | 支付金额(字符串) |
| currency | string | 5 | 货币代码 |
| gateway_interface_id | string | 6 | 支付网关接口 ID |
| gateway_payment_id | string | 7 | 网关内支付 ID(回调后填充) |
| gateway_tx_id | string | 8 | 网关交易流水号 |
| status | string | 9 | 支付状态 |
| paid_at | string | 10 | 支付时间(RFC3339) |
| created_at | string | 11 | 创建时间(RFC3339) |
| updated_at | string | 12 | 更新时间(RFC3339) |
支付状态流转
pending → success / failed / refunded
| 状态 | 说明 |
|---|---|
| pending | 待支付 |
| success | 支付成功 |
| failed | 支付失败 |
| refunded | 已退款 |
IntegrationService
插件系统与供应商集成的核心服务,负责插件定义管理、接口实例 CRUD、供应商询价/下单、上游实例同步和余额查询。是 Admin UI 插件管理页和 Upstream 页的主要后端服务。
RPC 列表
| RPC | 描述 | 权限 |
|---|---|---|
| ListProviders | 列出所有 Provider 插件接口 | admin |
| ListGateways | 列出所有 Gateway 插件接口 | admin |
| ListNotifiers | 列出所有 Notifier 插件接口 | admin |
| ListPlugins | 列出所有插件定义(.rn 文件) | admin |
| GetPlugin | 获取单个插件定义 | admin |
| ListPluginInterfaces | 列出所有插件接口实例 | admin |
| GetPluginInterface | 获取单个接口实例详情 | admin |
| CreatePluginInterface | 创建新的接口实例 | admin |
| UpdatePluginInterface | 更新接口实例配置 | admin |
| TogglePluginInterface | 启用/禁用接口实例 | admin |
| DeletePluginInterface | 删除接口实例 | admin |
| CheckPluginHealth | 检查接口实例健康状态 | admin |
| GetPluginAdminPage | 获取插件管理嵌入页面 HTML | admin |
| CallPluginAction | 调用插件自定义 action(admin panel bridge) | admin |
| RestartServer | 重启服务器 | admin |
| SyncProducts | 从 Provider 接口同步商品 | 已认证 |
| QueryPrice | 向 Provider 询价 | 已认证 |
| CreateUpstreamInstance | 向上游 Provider 下单开通实例 | 已认证 |
| GetInstanceStatus | 查询上游实例状态 | admin |
| SyncUpstreamInstances | 从上游同步实例列表 | admin |
| ImportUpstreamInstance | 导入上游实例到本地 | admin |
| GetUpstreamBalance | 查询上游账户余额 | admin |
ListProviders
描述
列出所有已启用的 Provider 类型插件接口实例,含健康状态和规格模板。
Request — EmptyRequest
无字段。
Response — ListProvidersResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| providers | repeated ProviderInfo | 1 | Provider 列表 |
ProviderInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| interface_id | string | 1 | 接口实例 ID |
| provider_name | string | 2 | 提供者名称 |
| provider_type | string | 3 | 类型:"upstream" / "first_party" |
| is_healthy | bool | 4 | 是否健康 |
| spec_template | SpecTemplate | 5 | 规格模板(含字段定义和分组) |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{}' \
localhost:50051 rustbill.integration.IntegrationService/ListProviders
ListGateways
描述
列出所有已启用的 Gateway 类型插件接口实例。
Request — EmptyRequest
无字段。
Response — ListGatewaysResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| gateways | repeated GatewayInfo | 1 | Gateway 列表 |
GatewayInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| gateway_id | string | 1 | 接口实例 ID |
| gateway_name | string | 2 | 网关名称 |
| is_healthy | bool | 3 | 是否健康 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{}' \
localhost:50051 rustbill.integration.IntegrationService/ListGateways
ListNotifiers
描述
列出所有已启用的 Notifier 类型插件接口实例。
Request — EmptyRequest
无字段。
Response — ListNotifiersResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| notifiers | repeated NotifierInfo | 1 | Notifier 列表 |
NotifierInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| channel_id | string | 1 | 接口实例 ID |
| channel_name | string | 2 | 渠道名称 |
| is_healthy | bool | 3 | 是否健康 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{}' \
localhost:50051 rustbill.integration.IntegrationService/ListNotifiers
ListPlugins
描述
列出所有已扫描的插件定义(plugins 表记录,由 PluginScanner 自动同步自 plugins/*.rn 文件)。
Request — ListPluginsRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| plugin_type | string? | 1 | 否 | 按类型过滤:"first_party_provider" / "upstream_provider" / "gateway" / "notifier" |
Response — ListPluginsResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| plugins | repeated PluginDef | 1 | 插件定义列表 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"plugin_type":"gateway"}' \
localhost:50051 rustbill.integration.IntegrationService/ListPlugins
GetPlugin
描述
获取单个插件定义详情。
Request — GetPluginRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 插件定义 ID(UUID) |
Response — GetPluginResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| plugin | PluginDef | 1 | 插件定义 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-..."}' \
localhost:50051 rustbill.integration.IntegrationService/GetPlugin
ListPluginInterfaces
描述
列出所有插件接口实例,可按类型过滤。
Request — ListPluginInterfacesRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| plugin_type | string? | 1 | 否 | 按类型过滤 |
Response — ListPluginInterfacesResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| interfaces | repeated PluginInterfaceInfo | 1 | 接口实例列表 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"plugin_type":"first_party_provider"}' \
localhost:50051 rustbill.integration.IntegrationService/ListPluginInterfaces
GetPluginInterface
描述
获取单个接口实例详情。
Request — GetPluginInterfaceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | 接口实例 ID(UUID) |
Response — GetPluginInterfaceResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| interface | PluginInterfaceInfo | 1 | 接口实例详情 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-..."}' \
localhost:50051 rustbill.integration.IntegrationService/GetPluginInterface
CreatePluginInterface
描述
基于插件定义创建新的接口实例。服务端自动从插件脚本提取 config_schema() 填充初始 config_schema 字段。
Request — CreatePluginInterfaceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| plugin_def_id | string | 1 | 是 | 插件定义 ID(UUID) |
| display_name | string | 2 | 是 | 显示名称(同一插件下唯一) |
| config_json | string | 3 | 是 | 初始配置 JSON 字符串 |
Response — CreatePluginInterfaceResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| interface | PluginInterfaceInfo | 1 | 创建的接口实例 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 插件定义不存在 |
| AlreadyExists | 同插件下 display_name 重复 |
| InvalidArgument | config_json 格式无效 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"plugin_def_id":"0192a123-...","display_name":"易支付-生产","config_json":"{\"api_url\":\"https://pay.example.com\",\"key\":\"xxx\"}"}' \
localhost:50051 rustbill.integration.IntegrationService/CreatePluginInterface
UpdatePluginInterface
描述
更新接口实例的配置或显示名称。更新 config_json 后自动 evict 脚本缓存,下次调用热加载。
Request — UpdatePluginInterfaceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | 接口实例 ID(UUID) |
| config_json | string? | 2 | 否 | 新配置 JSON |
| display_name | string? | 3 | 否 | 新显示名称 |
Response — UpdatePluginInterfaceResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| interface | PluginInterfaceInfo | 1 | 更新后的接口实例 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-...","config_json":"{\"api_url\":\"https://pay2.example.com\",\"key\":\"yyy\"}"}' \
localhost:50051 rustbill.integration.IntegrationService/UpdatePluginInterface
TogglePluginInterface
描述
启用或禁用接口实例。
Request — TogglePluginInterfaceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | 接口实例 ID(UUID) |
| enable | bool | 2 | 是 | true=启用, false=禁用 |
Response — TogglePluginInterfaceResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| interface | PluginInterfaceInfo | 1 | 更新后的接口实例 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-...","enable":true}' \
localhost:50051 rustbill.integration.IntegrationService/TogglePluginInterface
DeletePluginInterface
描述
删除接口实例。
Request — DeletePluginInterfaceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | 接口实例 ID(UUID) |
Response — DeletePluginInterfaceResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 接口实例不存在 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-..."}' \
localhost:50051 rustbill.integration.IntegrationService/DeletePluginInterface
CheckPluginHealth
描述
检查指定接口实例的健康状态。调用插件脚本的 health_check() 函数。
Request — CheckPluginHealthRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | 接口实例 ID(UUID) |
Response — CheckPluginHealthResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| is_healthy | bool | 1 | 是否健康 |
| error | string? | 2 | 错误信息(不健康时) |
| checked_at | string | 3 | 检查时间(RFC3339) |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-..."}' \
localhost:50051 rustbill.integration.IntegrationService/CheckPluginHealth
GetPluginAdminPage
描述
获取插件自定义管理页面的 HTML 内容,在 Admin UI 中以 iframe 嵌入渲染。
Request — GetPluginAdminPageRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | 接口实例 ID(UUID) |
Response — GetPluginAdminPageResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| html_content | string | 1 | HTML 页面内容 |
| title | string | 2 | 页面标题 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-..."}' \
localhost:50051 rustbill.integration.IntegrationService/GetPluginAdminPage
CallPluginAction
描述
在插件管理页面中调用插件的自定义 action 函数(通过 postMessage bridge)。function 名称为插件脚本中的函数名,args_json 自动合并到 config 中传入。
Request — CallPluginActionRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | 接口实例 ID(UUID) |
| function | string | 2 | 是 | 要调用的函数名(如 "admin_list_instances") |
| args_json | string | 3 | 是 | JSON 编码的参数(合并入 config) |
Response — CallPluginActionResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| result_json | string | 1 | JSON 编码的返回值 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-...","function":"admin_list_instances","args_json":"{}"}' \
localhost:50051 rustbill.integration.IntegrationService/CallPluginAction
RestartServer
描述
重启 RustBill 服务器(执行 graceful shutdown 后由 systemd/进程管理器重新拉起)。
Request — EmptyRequest
无字段。
Response — EmptyResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{}' \
localhost:50051 rustbill.integration.IntegrationService/RestartServer
SyncProducts
描述
从指定的 Provider 接口同步商品列表到本地。调用 Provider 插件的 sync_products() 函数。
详情同 ProductService 中的 SyncProducts。
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-..."}' \
localhost:50051 rustbill.integration.IntegrationService/SyncProducts
QueryPrice
描述
向 Provider 插件查询指定规格的价格。调用 Provider 插件的 query_price() 函数。
Request — QueryPriceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | Provider 接口 ID(UUID) |
| cpu_cores | uint32 | 2 | 是 | CPU 核心数 |
| memory_gb | uint32 | 3 | 是 | 内存(GB) |
| disk_gb | uint32 | 4 | 是 | 磁盘(GB) |
| bandwidth_mbps | uint32 | 5 | 是 | 带宽(Mbps) |
| region | string | 6 | 是 | 地域 |
| os | string | 7 | 是 | 操作系统 |
| extra_specs | map<string,string> | 8 | 否 | 扩展规格 |
Response — QueryPriceResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| amount | string | 1 | 价格(字符串金额) |
| currency | string | 2 | 货币代码 |
| billing_cycle | string | 3 | 计费周期 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-...","cpu_cores":2,"memory_gb":4,"disk_gb":50,"bandwidth_mbps":100,"region":"us-west","os":"ubuntu-22.04"}' \
localhost:50051 rustbill.integration.IntegrationService/QueryPrice
CreateUpstreamInstance
描述
向上游 Provider 下单创建实例。调用 Provider 插件的 create_instance() 函数。
Request — UpstreamOrderRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | Provider 接口 ID(UUID) |
| server_spec | ServerSpec | 2 | 是 | 服务器规格(引用 rustbill.order.ServerSpec) |
Response — UpstreamOrderResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| provider_instance_id | string | 1 | 上游实例 ID |
| status | string | 2 | 实例状态 |
| ip_address | string | 3 | IP 地址 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-...","server_spec":{"cpu_cores":2,"memory_gb":4,"disk_gb":50,"bandwidth_mbps":100,"region":"us-west","os":"ubuntu-22.04"}}' \
localhost:50051 rustbill.integration.IntegrationService/CreateUpstreamInstance
GetInstanceStatus
描述
查询上游 Provider 中指定实例的状态。
Request — InstanceStatusRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | Provider 接口 ID(UUID) |
| instance_id | string | 2 | 是 | 上游实例 ID |
Response — InstanceStatusResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| status | string | 1 | 实例状态 |
| ip_address | string | 2 | IP 地址 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-...","instance_id":"i-abc123"}' \
localhost:50051 rustbill.integration.IntegrationService/GetInstanceStatus
SyncUpstreamInstances
描述
从上游 Provider 同步实例列表。调用 Provider 插件的 sync_upstream_instances() 函数。
Request — SyncUpstreamInstancesRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | Provider 接口 ID(UUID) |
Response — SyncUpstreamInstancesResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| instances | repeated UpstreamInstanceItem | 1 | 上游实例列表 |
UpstreamInstanceItem
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| instance_id | string | 1 | 上游实例 ID |
| status | string | 2 | 实例状态 |
| ip_address | string | 3 | IP 地址 |
| created_at | string | 4 | 创建时间 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-..."}' \
localhost:50051 rustbill.integration.IntegrationService/SyncUpstreamInstances
ImportUpstreamInstance
描述
将上游实例导入本地 instances 表,建立关联。
Request — ImportUpstreamInstanceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | Provider 接口 ID(UUID) |
| instance_id | string | 2 | 是 | 上游实例 ID |
Response — ImportUpstreamInstanceResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| instance_id | string | 1 | 本地实例 ID |
| status | string | 2 | 实例状态 |
| ip_address | string | 3 | IP 地址 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-...","instance_id":"i-abc123"}' \
localhost:50051 rustbill.integration.IntegrationService/ImportUpstreamInstance
GetUpstreamBalance
描述
查询上游 Provider 的账户余额。调用 Provider 插件的 get_upstream_balance() 函数。
Request — GetUpstreamBalanceRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| interface_id | string | 1 | 是 | Provider 接口 ID(UUID) |
Response — GetUpstreamBalanceResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| balance | string | 1 | 余额(字符串金额) |
| currency | string | 2 | 货币代码 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"interface_id":"0192a123-..."}' \
localhost:50051 rustbill.integration.IntegrationService/GetUpstreamBalance
公共类型
PluginDef
插件定义(plugins 表记录,由 PluginScanner 从 plugins/*.rn 文件扫描同步)。
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | 插件定义 UUID |
| plugin_type | string | 2 | 类型:"first_party_provider" / "upstream_provider" / "gateway" / "notifier" |
| plugin_id | string | 3 | 插件标识符(如 "incus", "yipay", "webhook") |
| has_admin_page | bool | 4 | 是否有自定义管理页面(预计算列) |
| created_at | string | 5 | 创建时间(RFC3339) |
| updated_at | string | 6 | 更新时间(RFC3339) |
| version | string | 7 | 插件版本号(semver,来自 fn version(),默认 "0.0.0") |
PluginInterfaceInfo
接口实例(plugin_interfaces 表记录,管理员通过 UI 创建和管理)。
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | 接口实例 UUID |
| plugin_def_id | string | 2 | 关联插件定义 ID(FK) |
| plugin_type | string | 3 | 类型(反范式列) |
| plugin_id | string | 4 | 插件标识符(反范式列) |
| display_name | string | 5 | 显示名称 |
| enabled | bool | 6 | 是否启用 |
| config_json | string | 7 | 配置 JSON 字符串 |
| config_schema | string | 8 | 配置 JSON Schema(用于 UI 表单渲染) |
| created_at | string | 9 | 创建时间(RFC3339) |
| updated_at | string | 10 | 更新时间(RFC3339) |
| is_healthy | bool | 11 | 是否健康 |
| health_error | string? | 12 | 健康检查错误信息 |
| last_check_at | string? | 13 | 最近一次健康检查时间 |
| has_admin_page | bool | 14 | 是否有管理页面 |
| plugin_version | string | 15 | 插件版本快照(创建时从父插件复制) |
插件类型说明
| 类型 | DB 值 | 说明 |
|---|---|---|
| FirstPartyProvider | first_party_provider | 自建资源(KVM, Incus) |
| UpstreamProvider | upstream_provider | 上游分销(RustBill 上游) |
| PaymentGateway | gateway | 支付网关(易支付, 银行转账) |
| Notifier | notifier | 通知渠道(Webhook, Email) |
NotificationService
通知管理服务,负责列出已启用的通知渠道和手动发送测试通知。系统事件(订单支付、开通完成、工单分配等)自动广播到所有启用的通知渠道。
RPC 列表
| RPC | 描述 | 权限 |
|---|---|---|
| ListNotifiers | 列出所有通知渠道(含支持的事件类型) | admin |
| SendNotification | 手动发送测试通知 | admin |
ListNotifiers
描述
列出所有已启用的 Notifier 类型插件接口实例,含健康状态和支持的事件类型。
Request — EmptyRequest
无字段。
Response — ListNotifiersResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| notifiers | repeated NotifierInfo | 1 | 通知渠道列表 |
NotifierInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| channel_id | string | 1 | 接口实例 ID(UUID) |
| channel_name | string | 2 | 渠道显示名称 |
| supported_events | repeated string | 3 | 支持的事件类型列表(如 ["order_paid","order_provisioned"]) |
| is_healthy | bool | 4 | 是否健康 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{}' \
localhost:50051 rustbill.notification.NotificationService/ListNotifiers
const resp = await api.listNotifiers({});
// resp.notifiers → [{ channel_id, channel_name, supported_events, is_healthy }]
SendNotification
描述
手动发送测试通知到指定渠道或全部渠道。选择部分渠道时在其他客户端注册的回调等。
系统事件通知由服务端自动广播,无需手动调用此 RPC。
Request — SendNotificationRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| event_type | string | 1 | 是 | 事件类型(如 "order_paid", "test") |
| title | string | 2 | 是 | 通知标题 |
| body | string | 3 | 是 | 通知正文 |
| recipient | string | 4 | 是 | 接收者(如邮箱地址,为空时使用渠道默认接收者) |
| metadata | map<string,string> | 5 | 否 | 附加元数据 |
| channel_ids | repeated string | 6 | 否 | 目标渠道 ID 列表(空=全部渠道) |
Response — SendNotificationResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| results | repeated NotificationResult | 1 | 各渠道发送结果 |
NotificationResult
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| channel_id | string | 1 | 渠道 ID |
| success | bool | 2 | 是否发送成功 |
| error_message | string | 3 | 错误信息(失败时) |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | event_type 或 title 为空 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"event_type":"test","title":"测试通知","body":"这是一条测试消息","recipient":"[email protected]","metadata":{},"channel_ids":[]}' \
localhost:50051 rustbill.notification.NotificationService/SendNotification
const resp = await api.sendNotification({
event_type: "test",
title: "测试通知",
body: "这是一条测试消息",
recipient: "[email protected]",
metadata: {},
channel_ids: []
});
// resp.results → [{ channel_id, success, error_message }]
系统事件通知
以下事件会触发系统自动广播通知到所有已启用的通知渠道:
| 事件类型 | 触发时机 | 接收者 |
|---|---|---|
order_paid | 订单支付成功 | 客户邮箱 + 管理员通知邮箱 |
order_provisioned | 资源开通完成 | 客户邮箱 |
order_cancelled | 订单取消(含退款) | 客户邮箱 |
ticket_assigned | 工单分配处理人 | 新旧处理人 |
ticket_replied | 工单有新的公开回复 | 工单创建者 |
TicketService
工单管理服务,负责客户支持工单的全生命周期管理。支持创建工单、状态流转、处理人分配和回复(含内部备注)。Admin 用户可管理所有工单,Customer 用户只能查看和操作自己的工单。
RPC 列表
| RPC | 描述 | 权限 |
|---|---|---|
| CreateTicket | 创建工单 | 已认证 |
| ListTickets | 工单列表(分页+筛选) | admin 全量 / customer 自身 |
| GetTicket | 获取工单详情(含回复列表) | admin 全量 / customer 自身 |
| UpdateTicket | 更新工单状态/优先级/处理人/描述 | admin |
| CreateReply | 添加工单回复 | 已认证 |
| DeleteTicket | 删除工单 | admin |
CreateTicket
描述
创建新工单。Admin 创建时可指定 customer_id 为特定客户创建;Customer 用户创建时自动绑定自身客户。
Request — CreateTicketRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| customer_id | string | 1 | 是 | 客户 ID(admin 指定,customer 自动设置) |
| title | string | 2 | 是 | 工单标题 |
| description | string | 3 | 是 | 问题描述 |
| priority | string | 4 | 是 | 优先级:"low" / "medium" / "high" / "urgent"(默认 "medium") |
Response — CreateTicketResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| ticket | TicketInfo | 1 | 创建的工单信息 |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 标题为空或优先级无效 |
| NotFound | 指定客户不存在 |
| Unauthenticated | 未登录 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"customer_id":"0192a123-...","title":"服务器无法连接","description":"VPS 无法通过 SSH 连接,已尝试重启无效","priority":"high"}' \
localhost:50051 rustbill.ticket.TicketService/CreateTicket
const resp = await api.createTicket({
customer_id: "0192a123-...",
title: "服务器无法连接",
description: "VPS 无法通过 SSH 连接,已尝试重启无效",
priority: "high"
});
ListTickets
描述
分页查询工单列表。Admin 可查看所有工单,Customer 自动限定为自身工单。
Request — ListTicketsRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| pagination | PageRequest | 1 | 是 | 分页参数 |
| customer_id | string? | 2 | 否 | 按客户过滤(admin 可用) |
| status | string? | 3 | 否 | 按状态过滤:"pending" / "processing" / "resolved" / "closed" |
| priority | string? | 4 | 否 | 按优先级过滤:"low" / "medium" / "high" / "urgent" |
| assignee_user_id | string? | 5 | 否 | 按处理人过滤 |
Response — ListTicketsResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| tickets | repeated TicketInfo | 1 | 工单列表 |
| meta | PageMeta | 2 | 分页元数据 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"pagination":{"page":1,"page_size":20},"status":"pending","priority":"high"}' \
localhost:50051 rustbill.ticket.TicketService/ListTickets
GetTicket
描述
获取单个工单详情,包含完整回复列表。is_internal=true 的回复仅对 admin 可见,customer 请求中自动过滤。
Request — GetTicketRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 工单 ID(UUID) |
Response — GetTicketResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| ticket | TicketInfo | 1 | 工单信息(含回复列表) |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 工单不存在 |
| PermissionDenied | customer 用户无权查看他人工单 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.ticket.TicketService/GetTicket
UpdateTicket
描述
更新工单状态、优先级、处理人或描述。更改处理人时会触发通知系统向新旧处理人发送工单分配通知。
Request — UpdateTicketRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 工单 ID |
| status | string? | 2 | 否 | 新状态:"pending" / "processing" / "resolved" / "closed" |
| priority | string? | 3 | 否 | 新优先级 |
| assignee_user_id | string? | 4 | 否 | 新处理人用户 ID(变更时触发通知) |
| description | string? | 5 | 否 | 更新的问题描述 |
Response — UpdateTicketResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| ticket | TicketInfo | 1 | 更新后的工单信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 工单不存在 |
| InvalidArgument | 状态流转不合法 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-...","status":"processing","assignee_user_id":"0192b456-..."}' \
localhost:50051 rustbill.ticket.TicketService/UpdateTicket
CreateReply
描述
添加工单回复。is_internal=true 的回复为内部备注,仅 admin 可见。
Request — CreateReplyRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| ticket_id | string | 1 | 是 | 工单 ID(UUID) |
| content | string | 2 | 是 | 回复内容 |
| is_internal | bool | 3 | 是 | 是否为内部备注 |
Response — CreateReplyResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| reply | TicketReplyInfo | 1 | 创建的回复信息 |
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 工单不存在 |
| InvalidArgument | 回复内容为空 |
| Unauthenticated | 未登录 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"ticket_id":"0192a123-...","content":"已定位问题,正在修复","is_internal":false}' \
localhost:50051 rustbill.ticket.TicketService/CreateReply
const resp = await api.createReply({
ticket_id: "0192a123-...",
content: "请提供更多信息",
is_internal: false
});
DeleteTicket
描述
删除指定工单及其所有回复。
Request — DeleteTicketRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | 工单 ID(UUID) |
Response — DeleteTicketResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| NotFound | 工单不存在 |
| PermissionDenied | 非 admin 用户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192a123-4567-7890-abcd-ef0123456789"}' \
localhost:50051 rustbill.ticket.TicketService/DeleteTicket
公共类型
TicketInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | 工单 UUID |
| customer_id | string | 2 | 关联客户 ID |
| title | string | 3 | 工单标题 |
| description | string | 4 | 问题描述 |
| status | string | 5 | 状态:"pending" / "processing" / "resolved" / "closed" |
| priority | string | 6 | 优先级:"low" / "medium" / "high" / "urgent" |
| assignee_user_id | string | 7 | 处理人用户 ID |
| creator_user_id | string | 8 | 创建人用户 ID(admin 创建时设置) |
| created_at | string | 9 | 创建时间(RFC3339) |
| updated_at | string | 10 | 更新时间(RFC3339) |
| replies | repeated TicketReplyInfo | 11 | 回复列表 |
TicketReplyInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | 回复 UUID |
| ticket_id | string | 2 | 关联工单 ID |
| user_id | string | 3 | 回复人 admin 用户 ID(customer 回复时为空) |
| customer_id | string | 4 | 回复人客户 ID(admin 回复时为空) |
| content | string | 5 | 回复内容 |
| is_internal | bool | 6 | 是否为内部备注(customer 不可见) |
| created_at | string | 7 | 创建时间(RFC3339) |
工单状态流转
pending → processing → resolved → closed
| 状态 | 说明 |
|---|---|
| pending | 待处理 |
| processing | 处理中 |
| resolved | 已解决 |
| closed | 已关闭 |
工单优先级
| 优先级 | 值 | 说明 |
|---|---|---|
| low | 低 | 非紧急问题 |
| medium | 中 | 一般问题(默认) |
| high | 高 | 影响业务但可等待 |
| urgent | 紧急 | 业务完全中断 |
ApiKeyService
API Key 管理服务,用于创建和管理下游 API 访问密钥。API Key 可用于程序化访问 RustBill gRPC 接口,通过 ApiKeyAuthLayer 中间件认证,注入 DownstreamUser。
RPC 列表
| RPC | 描述 | 权限 |
|---|---|---|
| CreateApiKey | 创建新的 API Key(密钥仅返回一次) | admin 或已授权 customer |
| ListApiKeys | 列出指定客户的 API Key 列表 | admin 或已授权 customer |
| RevokeApiKey | 吊销(禁用)API Key | admin 或 API Key 所属客户 |
CreateApiKey
描述
创建新的 API Key。返回的 api_key 字段仅在创建时返回一次,之后无法再次获取,客户端应立即安全保存。
Request — CreateApiKeyRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| name | string | 1 | 是 | Key 名称(便于识别) |
| customer_id | string | 2 | 是 | 关联客户 ID(admin 需指定,customer 用户自动设置) |
Response — CreateApiKeyResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| api_key | string | 1 | 完整 API Key(仅此一次返回,需立即保存) |
| key_prefix | string | 2 | Key 前缀(用于识别,如 "rk_live_a1b2c3") |
| id | string | 3 | API Key 记录 ID(UUID) |
错误码
| 错误 | 说明 |
|---|---|
| InvalidArgument | 名称不能为空 |
| NotFound | 指定客户不存在 |
| PermissionDenied | 当前用户无权为此客户创建 Key(customer 需 can_create_api_keys=true) |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"name":"生产环境 API Key","customer_id":"0192a123-..."}' \
localhost:50051 rustbill.downstream.ApiKeyService/CreateApiKey
const resp = await api.createApiKey({
name: "生产环境 API Key",
customer_id: "0192a123-..."
});
// resp.api_key → "rk_live_a1b2c3d4e5f6..." (仅此一次)
// resp.key_prefix → "rk_live_a1b2c3"
// resp.id → "0192b456-..."
// 立即安全存储 api_key,之后无法再获取
ListApiKeys
描述
列出指定客户的所有 API Key(不包含完整密钥值,仅含前缀和元数据)。
Request — ListApiKeysRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| customer_id | string | 1 | 是 | 客户 ID(UUID) |
Response — ListApiKeysResponse
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| keys | repeated ApiKeyInfo | 1 | API Key 信息列表 |
ApiKeyInfo
| 字段 | 类型 | 编号 | 描述 |
|---|---|---|---|
| id | string | 1 | Key 记录 ID(UUID) |
| key_prefix | string | 2 | Key 前缀(如 "rk_live_a1b2c3") |
| name | string | 3 | Key 名称 |
| enabled | bool | 4 | 是否启用 |
| created_at | string | 5 | 创建时间(RFC3339) |
| last_used_at | string | 6 | 最近使用时间(RFC3339) |
| expires_at | string | 7 | 过期时间(RFC3339) |
错误码
| 错误 | 说明 |
|---|---|
| PermissionDenied | 非管理员且无此客户的 API Key 管理权限 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"customer_id":"0192a123-..."}' \
localhost:50051 rustbill.downstream.ApiKeyService/ListApiKeys
const resp = await api.listApiKeys({
customer_id: "0192a123-..."
});
// resp.keys → [{ id, key_prefix, name, enabled, created_at, last_used_at, expires_at }]
RevokeApiKey
描述
吊销(禁用)指定的 API Key。吊销后该 Key 无法再用于认证。
Request — RevokeApiKeyRequest
| 字段 | 类型 | 编号 | 必填 | 描述 |
|---|---|---|---|---|
| id | string | 1 | 是 | API Key 记录 ID(UUID) |
Response — RevokeApiKeyResponse
无字段。
错误码
| 错误 | 说明 |
|---|---|
| NotFound | API Key 不存在 |
| PermissionDenied | 非管理员且非该 Key 所属客户 |
示例
grpcurl -plaintext -H "authorization: Bearer <token>" \
-d '{"id":"0192b456-...}' \
localhost:50051 rustbill.downstream.ApiKeyService/RevokeApiKey
const resp = await api.revokeApiKey({
id: "0192b456-..."
});
API Key 使用方式
API Key 在 HTTP 请求中作为 Bearer token 携带:
Authorization: Bearer rk_live_a1b2c3d4e5f6...
认证成功后,ApiKeyAuthLayer 中间件在 request extensions 中注入 DownstreamUser:
DownstreamUser {
api_key_id: Uuid,
customer_id: Uuid,
permissions: Vec<String>
}
服务端通过 require_downstream() 守卫访问下游用户信息,并根据 customer_id 自动限定数据范围。
权限对照
| 操作 | Admin | Customer(can_create_api_keys=true) | Customer(can_create_api_keys=false) |
|---|---|---|---|
| CreateApiKey | 可创建任意客户 | 仅创建自身客户 | 禁止 |
| ListApiKeys | 可查看任意客户 | 仅查看自身客户 | 禁止 |
| RevokeApiKey | 可吊销任意 Key | 仅吊销自身 Key | 禁止 |
安全注意事项
api_key完整值仅在CreateApiKey响应中返回一次,服务端不存储明文密钥(仅存哈希)- API Key 应存储在安全的环境中(如环境变量、密钥管理服务)
- 建议定期轮换 API Key(创建新 Key + 吊销旧 Key)
- 吊销操作将
enabled设为false,不物理删除记录