12 KiB
插件开发 Skill
基于 CRM / 进销存插件的开发经验,提炼可复用的插件开发流程和模式。
触发场景
- 用户说"开发一个新插件"、"新建行业模块"、"创建插件"
- 用户提到需要在 ERP 平台上扩展新的业务模块
插件开发流程
第一步:需求分析 → 数据模型
-
确定插件 ID(如
erp-crm、erp-inventory) -
列出实体及其字段,为每个字段标注:
field_type: String/Integer/Float/Boolean/Date/DateTime/Uuid/Decimal/Jsonrequired/unique/searchable/filterable/sortablevisible_when: 条件显示表达式(如type == 'enterprise')ui_widget: 表单控件(input/select/textarea/datepicker/entity_select/number)options: select 类型的选项列表ref_entity/ref_label_field/ref_search_fields: 外键引用(entity_select 下拉)cascade_from/cascade_filter: 级联过滤(如选了客户后过滤联系人)scope_role: 标记数据权限的"所有者"字段(用于行级权限 self/department 模式)
-
确定实体间关系(
relations)和级联策略(cascade/nullify/restrict) -
确定是否需要行级数据权限(
data_scope = true)
第二步:编写 plugin.toml manifest
[metadata]
id = "erp-xxx"
name = "模块名称"
version = "0.1.0"
description = "描述"
author = "ERP Team"
min_platform_version = "0.1.0"
# 权限:code 必须是 {entity_name}.{list|manage},与下面的 entities[].name 完全一致
[[permissions]]
code = "entity.list"
name = "查看 XX"
description = "查看 XX 列表和详情"
data_scope_levels = ["self", "department", "department_tree", "all"]
[[permissions]]
code = "entity.manage"
name = "管理 XX"
description = "创建、编辑、删除 XX"
# 事件订阅(可选)
[events]
subscribe = ["order.*", "user.created"]
# 实体定义
[[schema.entities]]
name = "entity" # ← 这个 name 必须与上面 permissions 的 code 前缀一致
display_name = "实体名"
data_scope = true # 启用行级数据权限(可选)
[[schema.entities.fields]]
name = "field_name"
field_type = "String"
required = true
display_name = "字段名"
searchable = true # pg_trgm 全文搜索索引
filterable = true # Generated Column + B-tree 索引,前端渲染 Select 筛选
sortable = true # Generated Column + B-tree 索引,表格列头排序
# 外键引用示例
[[schema.entities.fields]]
name = "customer_id"
field_type = "Uuid"
ref_entity = "customer"
ref_label_field = "name"
ref_search_fields = ["name", "phone"]
ui_widget = "entity_select"
# 实体关系
[[schema.entities.relations]]
entity = "contact"
foreign_key = "customer_id"
on_delete = "cascade" # cascade | nullify | restrict
# 页面声明
[[ui.pages]]
type = "crud"
entity = "entity"
label = "页面标题"
icon = "icon-name"
enable_search = true
铁律:
permissions[].code的前缀(.之前的部分)必须与schema.entities[].name完全一致。每个实体必须声明.list和.manage两个权限。不一致会导致页面返回 403。
第三步:创建 Rust crate
mkdir -p crates/erp-plugin-xxx/src
Cargo.toml:
[package]
name = "erp-plugin-xxx"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.55"
serde = { workspace = true }
serde_json = { workspace = true }
src/lib.rs:
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
use crate::exports::erp::plugin::plugin_api::Guest;
struct XxxPlugin;
impl Guest for XxxPlugin {
fn init() -> Result<(), String> { Ok(()) }
fn on_tenant_created(_tenant_id: String) -> Result<(), String> { Ok(()) }
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> { Ok(()) }
}
export!(XxxPlugin);
WASM 内部可用的 Host API
插件在 init、on_tenant_created、handle_event 中可以调用以下 Host API:
| API | 说明 | 使用模式 |
|---|---|---|
db_insert(entity, data) |
插入记录,自动注入 tenant_id/id/created_at | 延迟刷新 |
db_query(entity, filter, pagination) |
查询记录,支持 filter 和分页 | 实时查询(自动先 flush pending writes) |
db_update(entity, id, data, version) |
更新记录,乐观锁 version 检查 | 延迟刷新 |
db_delete(entity, id) |
软删除 | 延迟刷新 |
event_publish(event_type, payload) |
发布领域事件 | 事务提交后投递 |
config_get(key) |
读取系统配置 | 预填充 |
log_write(level, message) |
写日志 | 立即 |
current_user() |
获取当前用户 id + tenant_id | 立即 |
check_permission(permission) |
检查当前用户权限 | 立即 |
filter 格式(JSON):
{"status": "active", "type": "enterprise"}
pagination 格式(JSON):
{"limit": 50, "offset": 0}
读后写一致性:db_query 会先自动 flush 所有 pending writes,确保能读取到自己刚插入的数据。
限制:Fuel 默认 1000 万条 WASM 指令,执行超时 30 秒。避免重计算循环。
第四步:注册到 workspace
根 Cargo.toml 的 [workspace] members 添加 "crates/erp-plugin-xxx"。
第五步:编译和转换
cargo build -p erp-plugin-xxx --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_xxx.wasm -o target/erp_plugin_xxx.component.wasm
第六步:上传和测试
PluginAdmin 页面上传 .component.wasm + plugin.toml:上传 → 安装 → 启用。
升级流程
上传新版本时,系统自动对比 schema 差异:
- 新增实体 → CREATE TABLE
- 已有实体新增 filterable/sortable/searchable 字段 → ALTER TABLE ADD Generated Column + 索引
- JSONB 字段新增 → 无需 DDL,更新 manifest 即可
- 热更新采用原子替换:先加载新版本到临时 slot,成功后替换旧版本,失败则旧版本继续运行
可用页面类型
| 类型 | 说明 | 必填配置 |
|---|---|---|
crud |
增删改查表格 | entity, label |
tree |
树形展示 | entity, label, id_field, parent_field, label_field |
detail |
详情 Drawer | entity, label, sections |
tabs |
标签页容器 | label, tabs(子页面列表) |
graph |
关系图谱 | entity, label, edges(节点关系定义) |
dashboard |
统计概览 | label, widgets(stat_card/pie_chart/funnel_chart 等) |
kanban |
看板拖拽 | entity, label, status_field |
detail section 类型
| 类型 | 说明 | 配置 |
|---|---|---|
fields |
字段描述列表 | label, fields(字段名数组) |
crud |
嵌套 CRUD 表格 | label, entity, filter_field |
字段属性速查
| 属性 | 说明 |
|---|---|
searchable |
可搜索,自动创建 pg_trgm GIN 索引 |
filterable |
可筛选,Generated Column + B-tree 索引,前端渲染 Select |
sortable |
可排序,Generated Column + B-tree 索引,表格列头排序 |
visible_when |
条件显示,格式 field == 'value' |
unique |
唯一约束,CREATE UNIQUE INDEX |
ref_entity |
外键引用,配合 ui_widget = "entity_select" |
cascade_from |
级联过滤,如选了客户后过滤联系人 |
scope_role |
数据权限所有者字段,行级权限 self/department 模式依赖此字段 |
ui_widget |
控件:select / textarea / entity_select / number / datepicker |
options |
select 选项 [{label, value}] |
validation.pattern |
正则校验 |
行级数据权限
在实体上设置 data_scope = true,并在权限中声明 data_scope_levels:
[[schema.entities]]
name = "order"
data_scope = true
[[schema.entities.fields]]
name = "owner_id"
field_type = "Uuid"
scope_role = true # 标记此字段为"所有者"
[[permissions]]
code = "order.list"
data_scope_levels = ["self", "department", "department_tree", "all"]
四种 scope 含义:
self— 只能看自己创建的(scope_role 字段匹配当前用户)department— 本部门成员创建的department_tree— 本部门及下级部门all— 不限
权限规则(铁律)
权限码必须与实体名完全一致。 这是系统自动校验的基础,不一致会导致 403。
命名约定
- manifest 中声明:
{entity_name}.{list|manage} - 数据库存储:
{manifest_id}.{entity_name}.{list|manage} - 运行时校验:
data_handler按 URL 中的 entity name 自动拼接为{manifest_id}.{entity_name}.{list|manage}
正确示例
# 实体名是 customer_tag → 权限码必须用 customer_tag
[[schema.entities]]
name = "customer_tag" # ← 实体名
display_name = "客户标签"
[[permissions]]
code = "customer_tag.list" # ← 必须与实体名一致!
name = "查看客户标签"
[[permissions]]
code = "customer_tag.manage" # ← 必须与实体名一致!
name = "管理客户标签"
规则总结
- 每个实体必须声明
.list和.manage两个权限(缺一不可) - 权限码前缀必须与
schema.entities[].name完全一致 - 安装时自动加 manifest_id 前缀(如
erp-crm.customer_tag.list) - REST API 动态检查,无精细权限时回退
plugin.list/plugin.admin
REST API
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/v1/plugins/{id}/{entity} |
列表(filter/search/sort) |
| POST | /api/v1/plugins/{id}/{entity} |
创建(required 校验) |
| GET | /api/v1/plugins/{id}/{entity}/{rid} |
详情 |
| PUT | /api/v1/plugins/{id}/{entity}/{rid} |
更新(乐观锁) |
| DELETE | /api/v1/plugins/{id}/{entity}/{rid} |
软删除 |
| GET | /api/v1/plugins/{id}/{entity}/count |
统计 |
| GET | /api/v1/plugins/{id}/{entity}/aggregate |
聚合(GROUP BY + COUNT) |
| POST | /api/v1/plugins/{id}/{entity}/aggregate-multi |
多聚合(COUNT + SUM/AVG/MIN/MAX) |
| GET | /api/v1/plugins/{id}/{entity}/timeseries |
时间序列聚合 |
| POST | /api/v1/plugins/{id}/{entity}/batch |
批量操作(delete/update,上限 100 条) |
aggregate-multi 请求体
{
"group_by": "status",
"aggregations": [
{"func": "sum", "field": "amount"},
{"func": "avg", "field": "price"}
],
"filter": {"status": "active"}
}
存储模型
所有业务数据存入 JSONB data 列,高频查询字段通过 Generated Column 提取为独立列(_f_{field_name}):
filterable/sortable/unique字段 →_f_{name}Generated Column + B-tree 索引searchableString 字段 →_f_{name}Generated Column + pg_trgm GIN 索引- 其余字段仅存在于 JSONB 中,无需 DDL
测试检查清单
cargo check --workspace通过cargo test --workspace通过- WASM 编译 + Component 转换成功
- 上传 → 安装 → 启用流程正常
- CRUD 完整可用
- 唯一字段重复插入返回冲突
- filter/search/sort 查询正常
- visible_when 条件字段动态显示
- 侧边栏菜单正确生成
- 行级数据权限正常(self/department/all)
- entity_select 外键引用和级联过滤正常
- dashboard 聚合数据正确
常见陷阱
- 权限码必须与实体名一致(P0 级):
permissions[].code的前缀必须与schema.entities[].name完全匹配。不一致会导致 403 - 每个实体必须同时声明
.list和.manage:漏掉.list会导致列表/详情查询 403,漏掉.manage会导致增删改 403 - 表名格式:
plugin_{sanitized_id}_{sanitized_entity},连字符变下划线 - edition 必须是 "2024"
- WIT 路径:
../erp-plugin-prototype/wit/plugin.wit,不是erp-plugin - JSONB 无外键约束,Uuid 字段不自动校验引用完整性(需在 WASM 中自行校验)
- Fuel 限制 1000 万,执行超时 30 秒,避免重计算循环
- manifest 中只写
entity.action,安装时自动加 manifest_id 前缀 db_query使用 Generated Column 路径(_f_前缀)过滤,确保字段标记了filterable- 热更新时已有实体的普通字段新增不需要 DDL(JSONB 天然支持),但
filterable/sortable新字段会自动 ALTER TABLE