Files
erp/wiki/wasm-plugin.md
iven 841766b168
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(用户管理): 修复用户列表页面加载失败问题
修复用户列表页面加载失败导致测试超时的问题,确保页面元素正确渲染
2026-04-19 08:46:28 +08:00

515 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# wasm-plugin (WASM 插件系统)
## 设计思想
ERP 平台通过 WASM 沙箱实现**安全、隔离、热插拔**的业务扩展。插件在 Wasmtime 运行时中执行,只能通过 WIT 定义的 Host API 与系统交互,无法直接访问数据库、文件系统或网络。
核心决策:
- **WASM 沙箱** — 插件代码在隔离环境中运行Host 控制所有资源访问
- **WIT 接口契约** — 通过 `.wit` 文件定义 Host ↔ 插件的双向接口bindgen 自动生成类型化绑定
- **Fuel 资源限制** — 通过燃料机制限制插件 CPU 使用,防止无限循环
- **声明式 Host API** — 插件通过 `db_insert` / `event_publish` 等函数操作数据Host 自动注入 tenant_id、校验权限
## 原型验证结果 (V1-V6)
| 验证项 | 状态 | 说明 |
|--------|------|------|
| V1: WIT 接口 + bindgen! 编译 | 通过 | `bindgen!({ path, world })` 生成 Host trait + Guest 绑定 |
| V2: Host 调用插件导出函数 | 通过 | `call_init()` / `call_handle_event()` / `call_on_tenant_created()` |
| V3: 插件回调 Host API | 通过 | 插件中 `host_api::db_insert()` 等正确回调到 HostState |
| V4: async 实例化桥接 | 通过 | `instantiate_async` 正常工作(调用方法本身是同步的) |
| V5: Fuel 资源限制 | 通过 | 低 fuel 时正确 trap不会无限循环 |
| V6: 从二进制动态加载 | 通过 | `.component.wasm` 文件加载,测试插件 110KB |
## 项目结构
```
crates/
erp-plugin-prototype/ ← Host 端运行时
wit/
plugin.wit ← WIT 接口定义
src/
lib.rs ← Engine/Store/Linker 创建、HostState + Host trait 实现
main.rs ← 手动测试入口(空)
tests/
test_plugin_integration.rs ← 6 个集成测试
erp-plugin-test-sample/ ← 测试插件
src/
lib.rs ← 实现 Guest trait调用 Host API
```
## WIT 接口定义
文件:`crates/erp-plugin-prototype/wit/plugin.wit`
```
package erp:plugin;
// Host 暴露给插件的 API插件 import
interface host-api {
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
db-update: func(entity, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
db-delete: func(entity: string, id: string) -> result<_, string>;
event-publish: func(event-type: string, payload: list<u8>) -> result<_, string>;
config-get: func(key: string) -> result<list<u8>, string>;
log-write: func(level: string, message: string);
current-user: func() -> result<list<u8>, string>;
check-permission: func(permission: string) -> result<bool, string>;
}
// 插件导出的 APIHost 调用)
interface plugin-api {
init: func() -> result<_, string>;
on-tenant-created: func(tenant-id: string) -> result<_, string>;
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
}
world plugin-world {
import host-api;
export plugin-api;
}
```
## 关键技术要点
### HasSelf<T> — Linker 注册模式
当 Store 数据类型(`HostState`)直接实现 `Host` trait 时,使用 `HasSelf<T>` 作为 `add_to_linker` 的类型参数:
```rust
use wasmtime::component::{HasSelf, Linker};
let mut linker = Linker::new(engine);
PluginWorld::add_to_linker::<_, HasSelf<HostState>>(&mut linker, |state| state)?;
```
`HasSelf<HostState>` 表示 `Data<'a> = &'a mut HostState`bindgen 生成的 `Host for &mut T` blanket impl 确保调用链正确。
### WASM Component vs Core Module
`wit_bindgen::generate!` 生成的是 core WASM 模块(`.wasm`),但 `Component::from_binary()` 需要 WASM Component 格式。转换步骤:
```bash
# 1. 编译为 core wasm
cargo build -p <plugin-crate> --target wasm32-unknown-unknown --release
# 2. 转换为 component
wasm-tools component new target/wasm32-unknown-unknown/release/<name>.wasm \
-o target/<name>.component.wasm
```
### Fuel 资源限制
```rust
let mut store = Store::new(engine, HostState::new());
store.set_fuel(1_000_000)?; // 分配 100 万 fuel
store.limiter(|state| &mut state.limits); // 内存限制
```
Fuel 不足时WASM 执行会 trap`wasm trap: interrupt`Host 可以捕获并处理。
### 调用方法 — 同步,非 async
bindgen 生成的调用方法(`call_init``call_handle_event`)是同步的:
```rust
// 正确
instance.erp_plugin_plugin_api().call_init(&mut store)?;
// 错误(不存在 async 版本的调用方法)
instance.erp_plugin_plugin_api().call_init(&mut store).await?;
```
但实例化可以异步:`PluginWorld::instantiate_async(&mut store, &component, &linker).await?`
## 关键文件
| 文件 | 职责 |
|------|------|
| `crates/erp-plugin-prototype/wit/plugin.wit` | WIT 接口定义Host API + Plugin API |
| `crates/erp-plugin-prototype/src/lib.rs` | Host 运行时Engine/Store 创建、HostState、Host trait 实现 |
| `crates/erp-plugin-prototype/tests/test_plugin_integration.rs` | V1-V6 集成测试 |
| `crates/erp-plugin-test-sample/src/lib.rs` | 测试插件Guest trait 实现 |
## 关联模块
- **[[architecture]]** — 插件架构是模块化单体的重要扩展机制
- **[[erp-core]]** — EventBus 事件将被桥接到插件的 `handle_event`
- **[[erp-server]]** — 未来集成插件运行时的组装点
---
# 插件制作完整流程
以下是从零创建一个新业务模块插件的完整步骤。
## 第一步:准备 WIT 接口
WIT 文件定义 Host 和插件之间的契约。现有接口位于 `crates/erp-plugin-prototype/wit/plugin.wit`
如果新插件需要扩展 Host API如新增文件上传、HTTP 代理等),在 `host-api` interface 中添加函数:
```wit
// 在 host-api 中新增
file-upload: func(filename: string, data: list<u8>) -> result<string, string>;
http-proxy: func(url: string, method: string, body: option<list<u8>>) -> result<list<u8>, string>;
```
如果插件需要新的生命周期钩子,在 `plugin-api` interface 中添加:
```wit
// 在 plugin-api 中新增
on-order-approved: func(order-id: string) -> result<_, string>;
```
修改 WIT 后,需要重新编译 Host crate 和所有插件。
## 第二步:创建插件 crate
`crates/` 下创建新的插件 crate
```bash
mkdir -p crates/erp-plugin-<业务名>
```
`Cargo.toml` 模板:
```toml
[package]
name = "erp-plugin-<业务名>"
version = "0.1.0"
edition = "2024"
description = "<业务描述> WASM 插件"
[lib]
crate-type = ["cdylib"] # 必须是 cdylib 才能编译为 WASM
[dependencies]
wit-bindgen = "0.55" # 生成 Guest 端绑定
serde = { workspace = true }
serde_json = { workspace = true }
```
将新 crate 加入 workspace编辑根 `Cargo.toml`
```toml
members = [
# ... 已有成员 ...
"crates/erp-plugin-<业务名>",
]
```
## 第三步:实现插件逻辑
创建 `src/lib.rs`,实现 `Guest` trait
```rust
//! <业务名> WASM 插件
use serde_json::json;
// 生成 Guest 端绑定(路径指向 Host crate 的 WIT 文件)
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
// 导入 Host APIbindgen 生成)
use crate::erp::plugin::host_api;
// 导入 Guest traitbindgen 生成)
use crate::exports::erp::plugin::plugin_api::Guest;
// 插件结构体(名称任意,但必须是模块级可见的)
struct MyPlugin;
impl Guest for MyPlugin {
/// 初始化 — 注册默认数据、订阅事件等
fn init() -> Result<(), String> {
host_api::log_write("info", "<业务名>插件初始化");
// 示例:创建默认配置
let config = json!({"default_category": "通用"}).to_string();
host_api::db_insert("<业务>_config", config.as_bytes())
.map_err(|e| format!("初始化失败: {}", e))?;
Ok(())
}
/// 租户创建时 — 初始化租户的默认数据
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
host_api::log_write("info", &format!("新租户: {}", tenant_id));
let data = json!({"tenant_id": tenant_id, "name": "默认仓库"}).to_string();
host_api::db_insert("warehouse", data.as_bytes())
.map_err(|e| format!("创建默认仓库失败: {}", e))?;
Ok(())
}
/// 处理订阅的事件
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
host_api::log_write("debug", &format!("收到事件: {}", event_type));
let data: serde_json::Value = serde_json::from_slice(&payload)
.map_err(|e| format!("解析事件失败: {}", e))?;
match event_type.as_str() {
"order.created" => {
// 处理订单创建事件
let order_id = data["id"].as_str().unwrap_or("");
host_api::log_write("info", &format!("新订单: {}", order_id));
}
"workflow.task.completed" => {
// 处理审批完成事件
let order_id = data["order_id"].as_str().unwrap_or("unknown");
let update = json!({"status": "approved"}).to_string();
host_api::db_update("purchase_order", order_id, update.as_bytes(), 1)
.map_err(|e| format!("更新失败: {}", e))?;
// 发布下游事件
let evt = json!({"order_id": order_id}).to_string();
host_api::event_publish("<业务>.order.approved", evt.as_bytes())
.map_err(|e| format!("发布事件失败: {}", e))?;
}
_ => {
host_api::log_write("debug", &format!("忽略事件: {}", event_type));
}
}
Ok(())
}
}
// 导出插件实例(宏会注册 Guest trait 实现)
export!(MyPlugin);
```
### Host API 速查
| 函数 | 签名 | 用途 |
|------|------|------|
| `db_insert` | `(entity, data) → result<record, string>` | 插入记录Host 自动注入 id/tenant_id/timestamp |
| `db_query` | `(entity, filter, pagination) → result<list, string>` | 查询记录,自动过滤 tenant_id + 排除软删除 |
| `db_update` | `(entity, id, data, version) → result<record, string>` | 更新记录,检查乐观锁 version |
| `db_delete` | `(entity, id) → result<_, string>` | 软删除记录 |
| `event_publish` | `(event_type, payload) → result<_, string>` | 发布领域事件 |
| `config_get` | `(key) → result<value, string>` | 读取系统配置 |
| `log_write` | `(level, message) → ()` | 写日志,自动关联 tenant_id + plugin_id |
| `current_user` | `() → result<user_info, string>` | 获取当前用户信息 |
| `check_permission` | `(permission) → result<bool, string>` | 检查当前用户权限 |
### 数据传递约定
所有 Host API 的数据参数使用 `list<u8>`(即 `Vec<u8>`),约定用 JSON 序列化:
```rust
// 构造数据
let data = json!({"sku": "ITEM-001", "quantity": 100}).to_string();
// 插入
let result_bytes = host_api::db_insert("inventory_item", data.as_bytes())
.map_err(|e| e.to_string())?;
// 解析返回
let record: serde_json::Value = serde_json::from_slice(&result_bytes)
.map_err(|e| e.to_string())?;
let new_id = record["id"].as_str().unwrap();
```
## 第四步:编译为 WASM
```bash
# 编译为 core WASM 模块
cargo build -p erp-plugin-<业务名> --target wasm32-unknown-unknown --release
# 转换为 WASM Component必须Host 只接受 Component 格式)
wasm-tools component new \
target/wasm32-unknown-unknown/release/erp_plugin_<业务名>.wasm \
-o target/erp_plugin_<业务名>.component.wasm
```
检查产物大小(目标 < 2MB
```bash
ls -la target/erp_plugin_<业务名>.component.wasm
```
## 第五步:编写集成测试
`crates/erp-plugin-prototype/tests/` 下创建测试文件或扩展现有测试
```rust
use anyhow::Result;
use erp_plugin_prototype::{create_engine, load_plugin};
fn wasm_path() -> String {
"../../target/erp_plugin_<业务名>.component.wasm".into()
}
#[tokio::test]
async fn test_<业务名>_init() -> Result<()> {
let wasm_bytes = std::fs::read(wasm_path())?;
let engine = create_engine()?;
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
// 调用 init
instance.erp_plugin_plugin_api().call_init(&mut store)?
.map_err(|e| anyhow::anyhow!(e))?;
// 验证 Host 端效果
let state = store.data();
assert!(state.db_ops.iter().any(|op| op.entity == "<业务>_config"));
Ok(())
}
#[tokio::test]
async fn test_<业务名>_handle_event() -> Result<()> {
let wasm_bytes = std::fs::read(wasm_path())?;
let engine = create_engine()?;
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
// 先初始化
instance.erp_plugin_plugin_api().call_init(&mut store)?
.map_err(|e| anyhow::anyhow!(e))?;
// 模拟事件
let payload = json!({"id": "ORD-001"}).to_string();
instance.erp_plugin_plugin_api()
.call_handle_event(&mut store, "order.created", payload.as_bytes())?
.map_err(|e| anyhow::anyhow!(e))?;
Ok(())
}
```
## 第六步:运行测试
```bash
# 先确保编译了 component
cargo build -p erp-plugin-<业务名> --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_<业务名>.wasm \
-o target/erp_plugin_<业务名>.component.wasm
# 运行集成测试
cargo test -p erp-plugin-prototype
```
## 流程速查图
```
1. 修改 WIT如需新接口 crates/erp-plugin-prototype/wit/plugin.wit
2. 创建插件 crate crates/erp-plugin-<名>/
- Cargo.toml (cdylib + wit-bindgen)
- src/lib.rs (impl Guest)
3. 编译 core wasm cargo build --target wasm32-unknown-unknown --release
4. 转为 component wasm-tools component new <in.wasm> -o <out.component.wasm>
5. 编写测试 crates/erp-plugin-prototype/tests/
6. 运行测试 cargo test -p erp-plugin-prototype
```
## 常见问题
### Q: "attempted to parse a wasm module with a component parser"
**A:** 使用了 core WASM 而非 Component运行 `wasm-tools component new` 转换
### Q: "cannot infer type of the type parameter D"
**A:** `add_to_linker` 需要显式指定 `HasSelf<T>``add_to_linker::<_, HasSelf<HostState>>(linker, |s| s)`
### Q: "wasm trap: interrupt"(非 fuel 耗尽)
**A:** 检查是否启用了 epoch_interruption 但未定期 bump epoch原型阶段建议只使用 fuel 限制
### Q: 插件中如何调试?
**A:** 使用 `host_api::log_write("debug", "message")` 输出日志Host `store.data().logs` 可查看所有日志
### Q: 如何限制插件内存?
**A:** 通过 `StoreLimitsBuilder` 配置
```rust
let limits = StoreLimitsBuilder::new()
.memory_size(10 * 1024 * 1024) // 10MB
.build();
```
## 后续规划
- **Phase 7**: 将原型集成到 erp-server替换模拟 Host API 为真实数据库操作
- **动态表**: 支持 `db_insert("dynamic_table", ...)` 自动创建/迁移表
- **前端集成**: PluginCRUDPage 组件根据 WIT 定义自动生成 CRUD 页面
- **插件市场**: 插件元数据版本管理签名验证
## 插件权限系统(关键)
### 权限码格式
插件数据操作的权限码由 `data_handler.rs` 中的 `compute_permission_code()` 按以下规则自动生成
```
{manifest_id}.{url_entity_name}.{action_suffix}
```
- `manifest_id`plugin.toml `[metadata].id` `erp-crm`
- `url_entity_name`REST API 路径中的实体名 `customer_tag`
- `action_suffix``list`读操作 `manage`写操作
| 操作 | 权限码示例 |
|------|-----------|
| 列表/详情 | `erp-crm.customer.list` |
| 创建/更新/删除 | `erp-crm.customer.manage` |
### 权限码命名铁律P0 级)
**`plugin.toml` `permissions[].code` 的前缀必须与 `schema.entities[].name` 完全一致。**
```
data_handler 生成:{manifest_id}.{url_entity_name}.{action}
↑ 来自 URL 路径中的 entity 参数
manifest 声明: {entity_name}.{action}
↑ 必须与 URL 中的 entity name 匹配
```
每个实体必须同时声明 `.list` `.manage` 两个权限
```toml
# ✅ 正确:权限码前缀与实体名一致
[[schema.entities]]
name = "customer_tag"
[[permissions]]
code = "customer_tag.list" # 匹配!
[[permissions]]
code = "customer_tag.manage" # 匹配!
# ❌ 错误:权限码用了简写,与实体名不一致 → 403
[[permissions]]
code = "tag.manage" # data_handler 生成 erp-crm.customer_tag.manage
# 但 DB 中只有 erp-crm.tag.manage → 403
```
**历史教训:** CRM 插件首个版本中`customer_tag` 实体的权限码写成了 `tag.manage``customer_relationship` 实体的权限码写成了 `relationship.list/manage`结果标签管理客户关系关系图谱三个页面全部 403修复迁移`m20260419_000038_fix_crm_permission_codes.rs`
### 权限注册流程
1. **插件安装时** `register_plugin_permissions()` manifest 中声明的权限批量 INSERT `permissions` `ON CONFLICT DO NOTHING` 保证幂等
2. **权限分配** `grant_permissions_to_admin()` 自动将权限分配给 admin 角色
3. **运行时校验** `data_handler.rs` `compute_permission_code()` URL entity name 生成权限码通过 `require_permission()` 检查 JWT 中的权限列表
### 已修复问题
| 问题 | 修复 |
|------|------|
| 权限未自动分配给 admin 角色 403 | `grant_permissions_to_admin()` install/enable 时自动调用 |
| 权限码与实体名不匹配 403 | 迁移 m20260419_000038 + plugin.toml 修正 |
### 插件 API 路由注意事项
- 后端路由使用 `Path<(Uuid, String)>` 解析 `plugin_id`必须是 UUID 格式
- 前端使用 `plugin.id`数据库 UUID而非 `manifest_id` `erp-crm`构建请求 URL
- 直接用 manifest_id 调用 API 会返回 `UUID parsing failed` 错误