Files
erp/docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md
iven ff352a4c24 feat(plugin): 集成 WASM 插件系统到主服务并修复链路问题
- 新增 erp-plugin crate:插件管理、WASM 运行时、动态表、数据 CRUD
- 新增前端插件管理页面(PluginAdmin/PluginCRUDPage)和 API 层
- 新增插件数据迁移(plugins/plugin_entities/plugin_event_subscriptions)
- 新增权限补充迁移(为已有租户补充 plugin.admin/plugin.list 权限)
- 修复 PluginAdmin 页面 InstallOutlined 图标不存在的崩溃问题
- 修复 settings 唯一索引迁移顺序错误(先去重再建索引)
- 更新 wiki 和 CLAUDE.md 反映插件系统集成状态
- 新增 dev.ps1 一键启动脚本
2026-04-15 23:32:02 +08:00

21 KiB
Raw Blame History

WASM 插件系统实施计划

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 为 ERP 平台引入 WASM 运行时插件系统,使行业模块可动态安装/启用/停用。

Architecture: 基础模块auth/config/workflow/message保持 Rust 编译时,新增 erp-plugin-runtime crate 封装 Wasmtime 运行时。插件通过宿主代理 API 访问数据库和事件总线,前端使用配置驱动 UI 渲染引擎自动生成 CRUD 页面。

Tech Stack: Rust + Wasmtime 27+ / WIT (wit-bindgen 0.24+) / SeaORM / Axum 0.8 / React 19 + Ant Design 6 + Zustand 5

Spec: docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md


File Structure

新建文件

crates/erp-plugin-runtime/
├── Cargo.toml
├── wit/
│   └── plugin.wit                    # WIT 接口定义
└── src/
    ├── lib.rs                        # crate 入口
    ├── manifest.rs                   # plugin.toml 解析
    ├── engine.rs                     # Wasmtime 引擎封装
    ├── host_api.rs                   # 宿主 APIdb/event/config/log
    ├── loader.rs                     # 插件加载器
    ├── schema.rs                     # 动态建表逻辑
    ├── error.rs                      # 插件错误类型
    └── wasm_module.rs                # ErpModule trait 的 WASM 适配器

crates/erp-server/migration/src/
└── m20260413_000032_create_plugins_table.rs   # plugins + plugin_schema_versions 表

crates/erp-server/src/
└── handlers/
    └── plugin.rs                     # 插件管理 + 数据 CRUD handler

apps/web/src/
├── api/
│   └── plugins.ts                    # 插件 API service
├── stores/
│   └── plugin.ts                     # PluginStore (Zustand)
├── pages/
│   ├── PluginAdmin.tsx               # 插件管理页面
│   └── PluginCRUDPage.tsx            # 通用 CRUD 渲染引擎
└── components/
    └── DynamicMenu.tsx               # 动态菜单组件

修改文件

Cargo.toml                                          # 添加 erp-plugin-runtime workspace member
crates/erp-core/src/module.rs                       # 升级 ErpModule trait v2
crates/erp-core/src/events.rs                       # 添加 subscribe_filtered
crates/erp-core/src/lib.rs                          # 导出新类型
crates/erp-auth/src/module.rs                       # 迁移到 v2 trait
crates/erp-config/src/module.rs                     # 迁移到 v2 trait
crates/erp-workflow/src/module.rs                   # 迁移到 v2 trait
crates/erp-message/src/module.rs                    # 迁移到 v2 trait
crates/erp-server/src/main.rs                       # 使用新注册系统 + 加载 WASM 插件
crates/erp-server/src/state.rs                      # 添加 PluginState
crates/erp-server/migration/src/lib.rs              # 注册新迁移
apps/web/src/App.tsx                                # 添加动态路由 + PluginAdmin 路由
apps/web/src/layouts/MainLayout.tsx                 # 使用 DynamicMenu

Chunk 1: ErpModule Trait v2 迁移 + EventBus 扩展

Task 1: 升级 ErpModule trait

Files:

  • Modify: crates/erp-core/src/module.rs

  • Modify: crates/erp-core/src/lib.rs

  • Step 1: 升级 ErpModule trait — 添加新方法(全部有默认实现)

crates/erp-core/src/module.rs 中,保留所有现有方法签名不变,追加新方法:

use std::collections::HashMap;

// 新增类型
pub enum ModuleType {
    Native,
    Wasm,
}

pub struct ModuleHealth {
    pub status: String,
    pub details: Option<String>,
}

pub struct ModuleContext {
    pub db: sea_orm::DatabaseConnection,
    pub event_bus: crate::events::EventBus,
    pub config: Arc<serde_json::Value>,
}

// 在 ErpModule trait 中追加(不改现有方法):
fn id(&self) -> &str { self.name() }  // 默认等于 name
fn module_type(&self) -> ModuleType { ModuleType::Native }
async fn on_startup(&self, _ctx: &ModuleContext) -> crate::error::AppResult<()> { Ok(()) }
async fn on_shutdown(&self) -> crate::error::AppResult<()> { Ok(()) }
async fn health_check(&self) -> crate::error::AppResult<ModuleHealth> {
    Ok(ModuleHealth { status: "ok".into(), details: None })
}
fn public_routes(&self) -> Option<axum::Router> { None }  // 需要 axum 依赖
fn protected_routes(&self) -> Option<axum::Router> { None }
fn migrations(&self) -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> { vec![] }
fn config_schema(&self) -> Option<serde_json::Value> { None }

注意: on_tenant_created/deleted 的签名暂不改动(加 ctx 参数是破坏性变更),在 Task 2 中单独处理。

  • Step 2: 升级 ModuleRegistry — 添加索引 + 拓扑排序 + build_routes

在同一个文件中扩展 ModuleRegistry

impl ModuleRegistry {
    pub fn get_module(&self, id: &str) -> Option<&Arc<dyn ErpModule>> { ... }
    pub fn build_routes(&self) -> (axum::Router, axum::Router) {
        // 遍历 modules收集 public_routes + protected_routes
    }
    fn topological_sort(&self) -> crate::error::AppResult<Vec<Arc<dyn ErpModule>>> {
        // 基于 dependencies() 的 Kahn 算法拓扑排序
    }
    pub async fn startup_all(&self, ctx: &ModuleContext) -> crate::error::AppResult<()> {
        // 按拓扑顺序调用 on_startup
    }
    pub async fn health_check_all(&self) -> HashMap<String, ModuleHealth> { ... }
}
  • Step 3: 更新 lib.rs 导出

crates/erp-core/src/lib.rs 追加:

pub use module::{ModuleType, ModuleHealth, ModuleContext};
  • Step 4: 更新 erp-core Cargo.toml 添加 axum 依赖

crates/erp-core/Cargo.toml[dependencies] 添加:

axum = { workspace = true }
sea-orm-migration = { workspace = true }
  • Step 5: 运行 cargo check --workspace 确保现有模块编译通过(所有新方法有默认实现)

  • Step 6: 迁移四个现有模块的 routes

erp-auth/src/module.rserp-config/src/module.rserp-workflow/src/module.rserp-message/src/module.rs

  • pub fn public_routes<S>() 关联函数改为 fn public_routes(&self) -> Option<Router> trait 方法
  • 同样处理 protected_routes
  • 添加 fn id() 返回与 name() 相同值

每个模块的改动模式:

// 之前: pub fn public_routes<S>() -> Router<S> where ... { Router::new().route(...) }
// 之后:
fn public_routes(&self) -> Option<axum::Router> {
    Some(axum::Router::new().route("/auth/login", axum::routing::post(auth_handler::login)).route("/auth/refresh", axum::routing::post(auth_handler::refresh)))
}
fn protected_routes(&self) -> Option<axum::Router> { Some(...) }
fn id(&self) -> &str { "auth" }  // 与 name() 相同
  • Step 7: 更新 main.rs 使用 build_routes

crates/erp-server/src/main.rs

// 替换手动 merge 为:
let (public_mod, protected_mod) = registry.build_routes();
let public_routes = Router::new()
    .merge(handlers::health::health_check_router())
    .merge(public_mod)  // 替代 erp_auth::AuthModule::public_routes()
    .route("/docs/openapi.json", ...)
    ...;
let protected_routes = protected_mod  // 替代手动 merge 四个模块
    .merge(handlers::audit_log::audit_log_router())
    ...;
  • Step 8: 运行 cargo check --workspace 确认全 workspace 编译通过

  • Step 9: 运行 cargo test --workspace 确认测试通过

  • Step 10: Commit

feat(core): upgrade ErpModule trait v2 with lifecycle hooks, route methods, and auto-collection

Task 2: EventBus subscribe_filtered 扩展

Files:

  • Modify: crates/erp-core/src/events.rs

  • Step 1: 添加类型化订阅支持

events.rs 中扩展 EventBus

use std::sync::RwLock;

pub type EventHandler = Box<dyn Fn(DomainEvent) + Send + Sync>;
pub type SubscriptionId = Uuid;

pub struct EventBus {
    sender: broadcast::Sender<DomainEvent>,
    handlers: Arc<RwLock<HashMap<String, Vec<(SubscriptionId, EventHandler)>>>>,
}

impl EventBus {
    pub fn subscribe_filtered(
        &self,
        event_type: &str,
        handler: EventHandler,
    ) -> SubscriptionId {
        let id = Uuid::now_v7();
        let mut handlers = self.handlers.write().unwrap();
        handlers.entry(event_type.to_string())
            .or_default()
            .push((id, handler));
        id
    }

    pub fn unsubscribe(&self, id: SubscriptionId) {
        let mut handlers = self.handlers.write().unwrap();
        for (_, list) in handlers.iter_mut() {
            list.retain(|(sid, _)| *sid != id);
        }
    }
}

修改 broadcast() 方法,在广播时同时分发给 handlers 中匹配的处理器。

  • Step 2: 运行 cargo test --workspace

  • Step 3: Commit

feat(core): add typed event subscription to EventBus

Chunk 2: 数据库迁移 + erp-plugin-runtime Crate

Task 3: 插件数据库表迁移

Files:

  • Create: crates/erp-server/migration/src/m20260413_000032_create_plugins_table.rs

  • Modify: crates/erp-server/migration/src/lib.rs

  • Step 1: 编写迁移文件

创建 pluginsplugin_schema_versionsplugin_event_subscriptions 三张表DDL 参见 spec §7.1)。

  • Step 2: 注册到 lib.rs 的迁移列表

  • Step 3: 运行 cargo run -p erp-server 验证迁移执行

  • Step 4: Commit

feat(db): add plugins, plugin_schema_versions, and plugin_event_subscriptions tables

Task 4: 创建 erp-plugin-runtime crate 骨架

Files:

  • Create: crates/erp-plugin-runtime/Cargo.toml

  • Create: crates/erp-plugin-runtime/src/lib.rs

  • Create: crates/erp-plugin-runtime/src/error.rs

  • Create: crates/erp-plugin-runtime/src/manifest.rs

  • Modify: Cargo.toml (workspace members)

  • Step 1: 创建 Cargo.toml

[package]
name = "erp-plugin-runtime"
version.workspace = true
edition.workspace = true

[dependencies]
erp-core = { workspace = true }
wasmtime = "27"
wasmtime-wasi = "27"
wit-bindgen = "0.24"
serde = { workspace = true }
serde_json = { workspace = true }
toml = "0.8"
uuid = { workspace = true }
chrono = { workspace = true }
sea-orm = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
axum = { workspace = true }
async-trait = { workspace = true }
  • Step 2: 更新根 Cargo.toml workspace members + dependencies

  • Step 3: 实现 manifest.rs — PluginManifest 类型 + 解析

定义 PluginManifestPluginInfoPermissionSetEntityDefFieldDefPageDef 等结构体,实现 fn parse(toml_str: &str) -> Result<PluginManifest>

  • Step 4: 实现 error.rs
#[derive(Debug, thiserror::Error)]
pub enum PluginError {
    #[error("Manifest 解析失败: {0}")]
    ManifestParse(String),
    #[error("WASM 加载失败: {0}")]
    WasmLoad(String),
    #[error("Host API 错误: {0}")]
    HostApi(String),
    #[error("插件未找到: {0}")]
    NotFound(String),
    #[error("依赖未满足: {0}")]
    DependencyUnmet(String),
}
  • Step 5: 实现 lib.rs — crate 入口 + re-exports

  • Step 6: 运行 cargo check --workspace

  • Step 7: Commit

feat(plugin): create erp-plugin-runtime crate with manifest parsing

Task 5: WIT 接口定义

Files:

  • Create: crates/erp-plugin-runtime/wit/plugin.wit

  • Step 1: 编写 WIT 文件

参见 spec 附录 D.1 的完整 plugin.wit 内容host interface + plugin interface + plugin-world

  • Step 2: 验证 WIT 语法
cargo install wit-bindgen-cli
wit-bindgen rust ./crates/erp-plugin-runtime/wit/plugin.wit --out-dir /tmp/test-bindgen
  • Step 3: Commit
feat(plugin): define WIT interface for host-plugin contract

Chunk 3: Host API + 插件加载器

Task 6: 实现 Host API 层

Files:

  • Create: crates/erp-plugin-runtime/src/host_api.rs

  • Step 1: 实现 PluginHostState 结构体

持有 dbtenant_idplugin_idevent_bus 等上下文。实现 db_insertdb_querydb_updatedb_deletedb_aggregate 方法,每个方法都:

  1. 自动注入 tenant_id 过滤
  2. 自动注入标准字段id, created_at 等)
  3. 参数化 SQL 防注入
  4. 自动审计日志
  • Step 2: 注册为 Wasmtime host functions

使用 wasmtime::Linker::func_wrap 将 host_api 方法注册到 WASM 实例。

  • Step 3: 编写单元测试

使用 mock 数据库测试 db_insert 自动注入 tenant_id、db_query 自动过滤。

  • Step 4: Commit
feat(plugin): implement host API layer with tenant isolation

Task 7: 实现插件加载器 + 动态建表

Files:

  • Create: crates/erp-plugin-runtime/src/engine.rs

  • Create: crates/erp-plugin-runtime/src/loader.rs

  • Create: crates/erp-plugin-runtime/src/schema.rs

  • Create: crates/erp-plugin-runtime/src/wasm_module.rs

  • Step 1: engine.rs — Wasmtime Engine 封装

单例 Engine + Store 工厂方法配置内存限制64MB 默认、fuel 消耗限制。

  • Step 2: schema.rs — 从 manifest 动态建表

create_entity_table(db, entity_def) 函数:生成 CREATE TABLE IF NOT EXISTS plugin_{name} (...) SQL包含所有标准字段 + tenant_id 索引。

  • Step 3: loader.rs — 从数据库加载 + 实例化

load_plugins(db, engine, event_bus) -> Vec<LoadedPlugin>:查询 plugins 表中 status=enabled 的记录,实例化 WASM调用 init(),注册事件处理器。

  • Step 4: wasm_module.rs — WasmModule实现 ErpModule trait

包装 WASM 实例,实现 ErpModule trait 的各方法(调用 WASM 导出函数)。

  • Step 5: 集成测试

测试完整的 load → init → db_insert → db_query 流程(使用真实 PostgreSQL

  • Step 6: Commit
feat(plugin): implement plugin loader with dynamic schema creation

Task 8: 插件管理 API + 数据 CRUD API

Files:

  • Create: crates/erp-server/src/handlers/plugin.rs

  • Modify: crates/erp-server/src/main.rs

  • Modify: crates/erp-server/src/state.rs

  • Step 1: 实现 plugin handler

上传(解析 plugin.toml + 存储 wasm_binary、列表、详情、启用建表+写状态)、停用、卸载(软删除)。

  • Step 2: 实现插件数据 CRUD

GET/POST/PUT/DELETE /api/v1/plugins/{plugin_id}/{entity} — 动态路由,从 manifest 查找 entity调用 host_api 执行操作。

  • Step 3: 注册路由到 main.rs

  • Step 4: 添加 PluginState 到 state.rs

impl FromRef<AppState> for erp_plugin_runtime::PluginState { ... }
  • Step 5: 运行 cargo test --workspace

  • Step 6: Commit

feat(server): add plugin management and dynamic CRUD API endpoints

Chunk 4: 前端配置驱动 UI

Task 9: PluginStore + API Service

Files:

  • Create: apps/web/src/api/plugins.ts

  • Create: apps/web/src/stores/plugin.ts

  • Step 1: plugins.ts API service

接口类型定义 + API 函数:listPluginsgetPluginuploadPluginenablePlugindisablePluginuninstallPlugingetPluginConfigupdatePluginConfiggetPluginDatacreatePluginDataupdatePluginDatadeletePluginData

  • Step 2: plugin.ts PluginStore
interface PluginStore {
  plugins: PluginInfo[];
  loading: boolean;
  fetchPlugins(): Promise<void>;
  getPageConfigs(): PluginPageConfig[];
}

启动时调用 fetchPlugins() 加载已启用插件列表及页面配置。

  • Step 3: Commit
feat(web): add plugin API service and PluginStore

Task 10: PluginCRUDPage 通用渲染引擎

Files:

  • Create: apps/web/src/pages/PluginCRUDPage.tsx

  • Step 1: 实现 PluginCRUDPage 组件

接收 PluginPageConfig 作为 props渲染

  • SearchBar: 从 filters 配置生成 Ant Design Form.Item 搜索条件
  • DataTable: 从 columns 配置生成 Ant Design Table 列
  • FormDialog: 从 form 配置或自动推导的 schema.entities 字段生成新建/编辑 Modal 表单
  • ActionBar: 从 actions 配置生成操作按钮

API 调用统一走 /api/v1/plugins/{plugin_id}/{entity} 路径。

  • Step 2: Commit
feat(web): implement PluginCRUDPage config-driven rendering engine

Task 11: 动态路由 + 动态菜单

Files:

  • Create: apps/web/src/components/DynamicMenu.tsx

  • Modify: apps/web/src/App.tsx

  • Modify: apps/web/src/layouts/MainLayout.tsx

  • Step 1: DynamicMenu 组件

usePluginStore 读取 getPageConfigs(),按 menu_group 分组生成 Ant Design Menu.Item追加到侧边栏。

  • Step 2: App.tsx 添加动态路由

在 private routes 中,遍历 PluginStore 的 pageConfigs为每个 CRUD 页面生成:

<Route path={page.path} element={<PluginCRUDPage config={page} />} />

同时添加 /plugin-admin 路由指向 PluginAdmin 页面。

  • Step 3: MainLayout.tsx 集成 DynamicMenu

替换硬编码的 bizMenuItems,追加插件动态菜单。

  • Step 4: 运行 pnpm dev 验证前端编译通过

  • Step 5: Commit

feat(web): add dynamic routing and menu generation from plugin configs

Task 12: 插件管理页面

Files:

  • Create: apps/web/src/pages/PluginAdmin.tsx

  • Step 1: 实现 PluginAdmin 页面

包含插件列表Table、上传按钮Upload、启用/停用/卸载操作、配置编辑 Modal。使用 Ant Design 组件。

  • Step 2: Commit
feat(web): add plugin admin page with upload/enable/disable/configure

Chunk 5: 第一个行业插件(进销存)

Task 13: 创建 erp-plugin-inventory

Files:

  • Create: crates/plugins/inventory/Cargo.toml

  • Create: crates/plugins/inventory/plugin.toml

  • Create: crates/plugins/inventory/src/lib.rs

  • Step 1: 创建插件项目

Cargo.toml crate-type = ["cdylib"],依赖 wit-bindgen + serde + serde_json。

  • Step 2: 编写 plugin.toml

完整清单spec §4 的进销存示例inventory_item、purchase_order 两个 entity3 个 CRUD 页面 + 1 个 custom 页面。

  • Step 3: 实现 lib.rs

使用 wit-bindgen 生成的绑定,实现 init()on_tenant_created()handle_event()

  • Step 4: 编译为 WASM
rustup target add wasm32-unknown-unknown
cargo build -p erp-plugin-inventory --target wasm32-unknown-unknown --release
  • Step 5: Commit
feat(inventory): create erp-plugin-inventory as first industry plugin

Task 14: 端到端集成测试

  • Step 1: 启动后端服务
cd docker && docker compose up -d
cd crates/erp-server && cargo run
  • Step 2: 通过 API 上传进销存插件
# 打包
cp target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm /tmp/
# 上传
curl -X POST http://localhost:3000/api/v1/admin/plugins/upload \
  -F "wasm=@/tmp/erp_plugin_inventory.wasm" \
  -F "manifest=@crates/plugins/inventory/plugin.toml"
  • Step 3: 启用插件 + 验证建表
curl -X POST http://localhost:3000/api/v1/admin/plugins/erp-inventory/enable
docker exec erp-postgres psql -U erp -c "\dt plugin_*"
  • Step 4: 测试 CRUD API
curl -X POST http://localhost:3000/api/v1/plugins/erp-inventory/inventory_item \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"sku":"ITEM001","name":"测试商品","quantity":100}'
curl http://localhost:3000/api/v1/plugins/erp-inventory/inventory_item \
  -H "Authorization: Bearer $TOKEN"
  • Step 5: 前端验证

启动 pnpm dev,验证:

  • 侧边栏出现"进销存"菜单组 + 子菜单

  • 点击"商品管理"显示 PluginCRUDPage

  • 可以新建/编辑/删除/搜索商品

  • Step 6: 测试停用 + 卸载

curl -X POST http://localhost:3000/api/v1/admin/plugins/erp-inventory/disable
curl -X DELETE http://localhost:3000/api/v1/admin/plugins/erp-inventory
# 验证数据表仍在
docker exec erp-postgres psql -U erp -c "\dt plugin_*"
  • Step 7: Commit
test(inventory): end-to-end integration test for plugin lifecycle

执行顺序

Chunk 1 (Tasks 1-2)  ← 先做,所有后续依赖 trait v2 和 EventBus 扩展
    ↓
Chunk 2 (Tasks 3-5)  ← 数据库表 + crate 骨架 + WIT
    ↓
Chunk 3 (Tasks 6-8)  ← 核心运行时 + API后端完成
    ↓
Chunk 4 (Tasks 9-12) ← 前端(可与 Chunk 5 并行)
    ↓
Chunk 5 (Tasks 13-14) ← 第一个插件 + E2E 验证

关键风险

风险 缓解
Wasmtime 版本与 WIT 不兼容 锁定 wasmtime = "27"CI 验证
axum Router 在 erp-core 中引入重依赖 考虑将 trait routes 方法改为返回路由描述结构体,在 erp-server 层构建 Router
动态建表安全性 仅允许白名单列类型,禁止 DDL 注入
前端 PluginCRUDPage 覆盖不足 先支持 text/number/date/select/currencycustom 页面后续迭代