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 一键启动脚本
This commit is contained in:
702
docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md
Normal file
702
docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md
Normal file
@@ -0,0 +1,702 @@
|
||||
# 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 # 宿主 API(db/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` 中,保留所有现有方法签名不变,追加新方法:
|
||||
|
||||
```rust
|
||||
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`:
|
||||
|
||||
```rust
|
||||
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` 追加:
|
||||
```rust
|
||||
pub use module::{ModuleType, ModuleHealth, ModuleContext};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 更新 erp-core Cargo.toml 添加 axum 依赖**
|
||||
|
||||
`crates/erp-core/Cargo.toml` 的 `[dependencies]` 添加:
|
||||
```toml
|
||||
axum = { workspace = true }
|
||||
sea-orm-migration = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行 `cargo check --workspace` 确保现有模块编译通过(所有新方法有默认实现)**
|
||||
|
||||
- [ ] **Step 6: 迁移四个现有模块的 routes**
|
||||
|
||||
对 `erp-auth/src/module.rs`、`erp-config/src/module.rs`、`erp-workflow/src/module.rs`、`erp-message/src/module.rs`:
|
||||
- 将 `pub fn public_routes<S>()` 关联函数改为 `fn public_routes(&self) -> Option<Router>` trait 方法
|
||||
- 同样处理 `protected_routes`
|
||||
- 添加 `fn id()` 返回与 `name()` 相同值
|
||||
|
||||
每个模块的改动模式:
|
||||
```rust
|
||||
// 之前: 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`:
|
||||
```rust
|
||||
// 替换手动 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`:
|
||||
|
||||
```rust
|
||||
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: 编写迁移文件**
|
||||
|
||||
创建 `plugins`、`plugin_schema_versions`、`plugin_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**
|
||||
|
||||
```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 类型 + 解析**
|
||||
|
||||
定义 `PluginManifest`、`PluginInfo`、`PermissionSet`、`EntityDef`、`FieldDef`、`PageDef` 等结构体,实现 `fn parse(toml_str: &str) -> Result<PluginManifest>`。
|
||||
|
||||
- [ ] **Step 4: 实现 error.rs**
|
||||
|
||||
```rust
|
||||
#[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 语法**
|
||||
|
||||
```bash
|
||||
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 结构体**
|
||||
|
||||
持有 `db`、`tenant_id`、`plugin_id`、`event_bus` 等上下文。实现 `db_insert`、`db_query`、`db_update`、`db_delete`、`db_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**
|
||||
|
||||
```rust
|
||||
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 函数:`listPlugins`、`getPlugin`、`uploadPlugin`、`enablePlugin`、`disablePlugin`、`uninstallPlugin`、`getPluginConfig`、`updatePluginConfig`、`getPluginData`、`createPluginData`、`updatePluginData`、`deletePluginData`。
|
||||
|
||||
- [ ] **Step 2: plugin.ts PluginStore**
|
||||
|
||||
```typescript
|
||||
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 页面生成:
|
||||
```tsx
|
||||
<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 两个 entity,3 个 CRUD 页面 + 1 个 custom 页面。
|
||||
|
||||
- [ ] **Step 3: 实现 lib.rs**
|
||||
|
||||
使用 wit-bindgen 生成的绑定,实现 `init()`、`on_tenant_created()`、`handle_event()`。
|
||||
|
||||
- [ ] **Step 4: 编译为 WASM**
|
||||
|
||||
```bash
|
||||
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: 启动后端服务**
|
||||
|
||||
```bash
|
||||
cd docker && docker compose up -d
|
||||
cd crates/erp-server && cargo run
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 通过 API 上传进销存插件**
|
||||
|
||||
```bash
|
||||
# 打包
|
||||
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: 启用插件 + 验证建表**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
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: 测试停用 + 卸载**
|
||||
|
||||
```bash
|
||||
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/currency,custom 页面后续迭代 |
|
||||
985
docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md
Normal file
985
docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md
Normal file
@@ -0,0 +1,985 @@
|
||||
# WASM 插件系统设计规格
|
||||
|
||||
> 日期:2026-04-13
|
||||
> 状态:审核通过 (v2 — 修复安全/多租户/迁移问题)
|
||||
> 关联:`docs/superpowers/specs/2026-04-10-erp-platform-base-design.md`
|
||||
> Review 历史:v1 首次审核 → 修复 C1-C4 关键问题 + I1-I5 重要问题 → v2 审核通过
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
ERP 平台底座 Phase 1-6 已全部完成,包含 auth、config、workflow、message 四大基础模块。
|
||||
当前系统是一个"模块化形状的单体"——模块以独立 crate 存在,但集成方式是编译时硬编码(main.rs 手动注册路由、合并迁移、启动后台任务)。
|
||||
|
||||
**核心矛盾:** Rust 的静态编译特性不支持运行时热插拔,但产品目标是"通用基座 + 行业插件"架构。
|
||||
|
||||
**本设计的目标:** 引入 WASM 运行时插件系统,使行业模块(进销存、生产、财务等)可以动态安装、启用、停用,无需修改基座代码。
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|--------|------|------|
|
||||
| 插件范围 | 仅行业模块动态化,基础模块保持 Rust 编译时 | 基础模块变更频率低、可靠性要求高,适合编译时保证 |
|
||||
| 插件技术 | WebAssembly (Wasmtime) | Rust 原生运行时,性能接近原生,沙箱安全 |
|
||||
| 数据库访问 | 宿主代理 API | 宿主自动注入 tenant_id、软删除、审计日志,插件无法绕过 |
|
||||
| 前端 UI | 配置驱动 | ERP 80% 页面是 CRUD,配置驱动覆盖大部分场景 |
|
||||
| 插件管理 | 内置插件商店 | 类似 WordPress 模型,管理后台上传 WASM 包 |
|
||||
| WASM 运行时 | Wasmtime | Bytecode Alliance 维护,Rust 原生,Cranelift JIT |
|
||||
|
||||
## 3. 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ erp-server │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ ModuleRegistry v2 │ │
|
||||
│ │ ┌─────────────────┐ ┌──────────────────────────┐│ │
|
||||
│ │ │ Native Modules │ │ Wasmtime Runtime ││ │
|
||||
│ │ │ ┌──────┐┌──────┐│ │ ┌──────┐┌──────┐┌──────┐││ │
|
||||
│ │ │ │ auth ││config ││ │ │进销存 ││ 生产 ││ 财务 │││ │
|
||||
│ │ │ ├──────┤├──────┤│ │ └──┬───┘└──┬───┘└──┬───┘││ │
|
||||
│ │ │ │workflow│msg ││ │ └────────┼────────┘ ││ │
|
||||
│ │ │ └──────┘└──────┘│ │ Host API Layer ││ │
|
||||
│ │ └─────────────────┘ └──────────────────────────┘│ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ ↕ EventBus │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ 统一 Axum Router │ │
|
||||
│ │ /api/v1/auth/* /api/v1/plugins/{id}/* │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
↕
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React SPA) │
|
||||
│ ┌──────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ 固定路由 │ │ 动态路由 (PluginRegistry Store) │ │
|
||||
│ │ /users /roles │ │ /inventory/* /production/* │ │
|
||||
│ └──────────────┘ └──────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐│
|
||||
│ │ PluginCRUDPage — 配置驱动的通用 CRUD 渲染引擎 ││
|
||||
│ └──────────────────────────────────────────────────────┘│
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 插件清单 (Plugin Manifest)
|
||||
|
||||
每个 WASM 插件包含一个 `plugin.toml` 清单文件:
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
id = "erp-inventory" # 全局唯一 ID,kebab-case
|
||||
name = "进销存管理" # 显示名称
|
||||
version = "1.0.0" # 语义化版本
|
||||
description = "商品/采购/销售/库存管理"
|
||||
author = "ERP Team"
|
||||
min_platform_version = "1.0.0" # 最低基座版本要求
|
||||
|
||||
[dependencies]
|
||||
modules = ["auth", "workflow"] # 依赖的基础模块 ID 列表
|
||||
|
||||
[permissions]
|
||||
database = true # 需要数据库访问
|
||||
events = true # 需要发布/订阅事件
|
||||
config = true # 需要读取系统配置
|
||||
files = false # 是否需要文件存储
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "inventory_item"
|
||||
fields = [
|
||||
{ name = "sku", type = "string", required = true, unique = true },
|
||||
{ name = "name", type = "string", required = true },
|
||||
{ name = "quantity", type = "integer", default = 0 },
|
||||
{ name = "unit", type = "string", default = "个" },
|
||||
{ name = "category_id", type = "uuid", nullable = true },
|
||||
{ name = "unit_price", type = "decimal", precision = 10, scale = 2 },
|
||||
]
|
||||
indexes = [["sku"], ["category_id"]]
|
||||
|
||||
[[schema.entities]]
|
||||
name = "purchase_order"
|
||||
fields = [
|
||||
{ name = "order_no", type = "string", required = true, unique = true },
|
||||
{ name = "supplier_id", type = "uuid" },
|
||||
{ name = "status", type = "string", default = "draft" },
|
||||
{ name = "total_amount", type = "decimal", precision = 12, scale = 2 },
|
||||
{ name = "order_date", type = "date" },
|
||||
]
|
||||
|
||||
[events]
|
||||
published = ["inventory.stock.low", "purchase_order.created", "purchase_order.approved"]
|
||||
subscribed = ["workflow.task.completed"]
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
name = "商品管理"
|
||||
path = "/inventory/items"
|
||||
entity = "inventory_item"
|
||||
type = "crud"
|
||||
icon = "ShoppingOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[ui.pages]]
|
||||
name = "采购管理"
|
||||
path = "/inventory/purchase"
|
||||
entity = "purchase_order"
|
||||
type = "crud"
|
||||
icon = "ShoppingCartOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[ui.pages]]
|
||||
name = "库存盘点"
|
||||
path = "/inventory/stocktaking"
|
||||
type = "custom"
|
||||
menu_group = "进销存"
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `schema.entities` 声明的表自动注入标准字段:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- `permissions` 控制插件可调用的宿主 API 范围(最小权限原则)
|
||||
- `ui.pages.type` 为 `crud` 时由通用渲染引擎自动生成页面,`custom` 时由插件处理渲染逻辑
|
||||
- 插件事件命名使用 `{plugin_id}.{entity}.{action}` 三段式,避免与基础模块的 `{module}.{action}` 二段式冲突
|
||||
- 动态创建的表使用 `plugin_{entity_name}` 格式,所有租户共享同一张表,通过 `tenant_id` 列实现行级隔离(与现有表模式一致)
|
||||
|
||||
## 5. 宿主 API (Host Functions)
|
||||
|
||||
WASM 插件通过宿主暴露的函数访问系统资源,这是插件与外部世界的唯一通道:
|
||||
|
||||
### 5.1 API 定义
|
||||
|
||||
```rust
|
||||
/// 宿主暴露给 WASM 插件的 API 接口
|
||||
/// 通过 Wasmtime Linker 注册为 host functions
|
||||
trait PluginHostApi {
|
||||
// === 数据库操作 ===
|
||||
|
||||
/// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段)
|
||||
fn db_insert(&mut self, entity: &str, data: &[u8]) -> Result<Vec<u8>>;
|
||||
|
||||
/// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤)
|
||||
fn db_query(&mut self, entity: &str, filter: &[u8], pagination: &[u8]) -> Result<Vec<u8]>;
|
||||
|
||||
/// 更新记录(自动检查 version 乐观锁)
|
||||
fn db_update(&mut self, entity: &str, id: &str, data: &[u8], version: i64) -> Result<Vec<u8]>;
|
||||
|
||||
/// 软删除记录
|
||||
fn db_delete(&mut self, entity: &str, id: &str) -> Result<()>;
|
||||
|
||||
/// 原始查询(仅允许 SELECT,自动注入 tenant_id 过滤)
|
||||
fn db_raw_query(&mut self, sql: &str, params: &[u8]) -> Result<Vec<u8]>;
|
||||
|
||||
// === 事件总线 ===
|
||||
|
||||
/// 发布领域事件
|
||||
fn event_publish(&mut self, event_type: &str, payload: &[u8]) -> Result<()>;
|
||||
|
||||
// === 配置 ===
|
||||
|
||||
/// 读取系统配置(插件作用域内)
|
||||
fn config_get(&mut self, key: &str) -> Result<Vec<u8]>;
|
||||
|
||||
// === 日志 ===
|
||||
|
||||
/// 写日志(自动关联 tenant_id + plugin_id)
|
||||
fn log_write(&mut self, level: &str, message: &str);
|
||||
|
||||
// === 用户/权限 ===
|
||||
|
||||
/// 获取当前用户信息
|
||||
fn current_user(&mut self) -> Result<Vec<u8]>;
|
||||
|
||||
/// 检查当前用户权限
|
||||
fn check_permission(&mut self, permission: &str) -> Result<bool>;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 安全边界
|
||||
|
||||
插件运行在 WASM 沙箱中,安全策略如下:
|
||||
|
||||
1. **权限校验** — 插件只能调用清单 `permissions` 中声明的宿主函数,未声明的调用在加载时被拦截
|
||||
2. **租户隔离** — 所有 `db_*` 操作自动注入 `tenant_id`,插件无法绕过多租户隔离。使用行级隔离(共享表 + tenant_id 过滤),与现有基础模块保持一致
|
||||
3. **资源限制** — 每个插件有独立的资源配额(内存上限、CPU 时间、API 调用频率)
|
||||
4. **审计记录** — 所有写操作自动记录审计日志
|
||||
5. **SQL 安全** — 不暴露原始 SQL 接口,`db_aggregate` 使用结构化查询对象,宿主层安全构建参数化 SQL
|
||||
6. **文件/网络隔离** — 插件不能直接访问文件系统或网络
|
||||
|
||||
### 5.3 数据流
|
||||
|
||||
```
|
||||
WASM 插件 宿主安全层 PostgreSQL
|
||||
┌──────────┐ ┌───────────────┐ ┌──────────┐
|
||||
│ 调用 │ ── Host Call ──→ │ 1. 权限校验 │ │ │
|
||||
│ db_insert │ │ 2. 注入标准字段 │ ── SQL ──→ │ INSERT │
|
||||
│ │ │ 3. 注入 tenant │ │ INTO │
|
||||
│ │ ←─ JSON 结果 ── │ 4. 写审计日志 │ │ │
|
||||
└──────────┘ └───────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## 6. 插件生命周期
|
||||
|
||||
### 6.1 状态机
|
||||
|
||||
```
|
||||
上传 WASM 包
|
||||
│
|
||||
▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ uploaded │───→│ installed │───→│ enabled │───→│ running │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
│ │
|
||||
│ ┌──────────┘
|
||||
│ ▼
|
||||
┌──────────┐
|
||||
│ disabled │←── 运行时错误自动停用
|
||||
└──────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│uninstalled│ ── 软删除插件记录,保留数据表和数据
|
||||
└──────────┘
|
||||
│
|
||||
▼ (可选,需管理员二次确认)
|
||||
┌──────────┐
|
||||
│ purged │ ── 真正删除数据表 + 数据导出备份
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### 6.2 各阶段操作
|
||||
|
||||
| 阶段 | 操作 |
|
||||
|------|------|
|
||||
| uploaded → installed | 校验清单格式、验证依赖模块存在、检查 min_platform_version |
|
||||
| installed → enabled | 根据 `schema.entities` 创建数据表(带 `plugin_` 前缀)、写入启用状态 |
|
||||
| enabled → running | 服务启动时:Wasmtime 实例化、注册 Host Functions、调用 `init()`、注册事件处理器、注册前端路由 |
|
||||
| running → disabled | 停止 WASM 实例、注销事件处理器、注销路由 |
|
||||
| disabled → uninstalled | 软删除插件记录(设置 `deleted_at`),**保留数据表和数据不变**,清理事件订阅记录 |
|
||||
| uninstalled → purged | 数据导出备份后,删除 `plugin_*` 数据表。**需要管理员二次确认 + 数据导出完成** |
|
||||
|
||||
### 6.3 启动加载流程
|
||||
|
||||
```rust
|
||||
async fn load_plugins(db: &DatabaseConnection) -> Vec<LoadedPlugin> {
|
||||
// 1. 查询所有 enabled 状态的插件
|
||||
let plugins = Plugin::find()
|
||||
.filter(status.eq("enabled"))
|
||||
.filter(deleted_at.is_null())
|
||||
.all(db).await?;
|
||||
|
||||
let mut loaded = Vec::new();
|
||||
for plugin in plugins {
|
||||
// 2. 初始化 Wasmtime Engine(复用全局 Engine)
|
||||
let module = Module::from_binary(&engine, &plugin.wasm_binary)?;
|
||||
|
||||
// 3. 创建 Linker,根据 permissions 注册对应的 Host Functions
|
||||
let mut linker = Linker::new(&engine);
|
||||
register_host_functions(&mut linker, &plugin.permissions)?;
|
||||
|
||||
// 4. 实例化
|
||||
let instance = linker.instantiate_async(&mut store, &module).await?;
|
||||
|
||||
// 5. 调用插件的 init() 入口函数
|
||||
if let Ok(init) = instance.get_typed_func::<(), ()>(&mut store, "init") {
|
||||
init.call_async(&mut store, ()).await?;
|
||||
}
|
||||
|
||||
// 6. 注册事件处理器
|
||||
for sub in &plugin.manifest.events.subscribed {
|
||||
event_bus.subscribe_filtered(sub, plugin_handler(plugin.id, instance.clone()));
|
||||
}
|
||||
|
||||
loaded.push(LoadedPlugin { plugin, instance, store });
|
||||
}
|
||||
|
||||
// 7. 依赖排序验证
|
||||
validate_dependencies(&loaded)?;
|
||||
|
||||
Ok(loaded)
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 数据库 Schema
|
||||
|
||||
### 7.1 新增表
|
||||
|
||||
```sql
|
||||
-- 插件注册表
|
||||
CREATE TABLE plugins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
plugin_id VARCHAR(100) NOT NULL, -- 清单中的唯一 ID
|
||||
name VARCHAR(200) NOT NULL,
|
||||
plugin_version VARCHAR(20) NOT NULL, -- 插件语义化版本(避免与乐观锁 version 混淆)
|
||||
description TEXT,
|
||||
manifest JSONB NOT NULL, -- 完整清单 JSON
|
||||
wasm_binary BYTEA NOT NULL, -- 编译后的 WASM 二进制
|
||||
status VARCHAR(20) DEFAULT 'installed',
|
||||
-- uploaded / installed / enabled / disabled / error
|
||||
permissions JSONB NOT NULL,
|
||||
error_message TEXT,
|
||||
schema_version INTEGER DEFAULT 1, -- 插件数据 schema 版本
|
||||
config JSONB DEFAULT '{}', -- 插件配置
|
||||
-- 标准字段
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID,
|
||||
updated_by UUID,
|
||||
deleted_at TIMESTAMPTZ, -- 软删除(卸载不删数据)
|
||||
row_version INTEGER NOT NULL DEFAULT 1, -- 乐观锁版本
|
||||
UNIQUE(tenant_id, plugin_id)
|
||||
);
|
||||
|
||||
-- 插件 schema 版本跟踪(用于动态表的版本管理)
|
||||
CREATE TABLE plugin_schema_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id VARCHAR(100) NOT NULL, -- 全局唯一的插件 ID
|
||||
entity_name VARCHAR(100) NOT NULL, -- 实体名
|
||||
schema_version INTEGER NOT NULL DEFAULT 1, -- 当前 schema 版本
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(plugin_id, entity_name)
|
||||
);
|
||||
|
||||
-- 插件事件订阅记录
|
||||
CREATE TABLE plugin_event_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
plugin_id VARCHAR(100) NOT NULL,
|
||||
event_type VARCHAR(200) NOT NULL,
|
||||
handler_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 7.2 动态数据表
|
||||
|
||||
插件安装时根据 `manifest.schema.entities` 自动创建数据表:
|
||||
|
||||
- 表名格式:`plugin_{entity_name}`
|
||||
- **行级隔离模式**:所有租户共享同一张 `plugin_*` 表,通过 `tenant_id` 列过滤实现隔离(与现有基础模块的表保持一致)
|
||||
- 首次创建表时使用 `IF NOT EXISTS`(幂等),后续租户安装同一插件时复用已有表
|
||||
- 自动包含标准字段:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- 索引自动创建:主键 + `tenant_id`(必选)+ 清单中声明的自定义索引
|
||||
- **注意**:此方式绕过 SeaORM Migration 系统,属于合理偏差——插件是运行时动态加载的,其 schema 无法在编译时通过静态迁移管理。宿主维护 `plugin_schema_versions` 表跟踪每个插件的 schema 版本
|
||||
|
||||
## 8. 配置驱动 UI
|
||||
|
||||
### 8.1 前端架构
|
||||
|
||||
```
|
||||
插件 manifest.ui.pages
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ PluginStore │ Zustand Store,从 /api/v1/plugins/:id/pages 加载
|
||||
│ (前端插件注册表) │ 缓存所有已启用插件的页面配置
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ DynamicRouter │ React Router,根据 PluginStore 自动生成路由
|
||||
│ (动态路由层) │ 懒加载 PluginCRUDPage / PluginDashboard
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ PluginCRUDPage │ 通用 CRUD 页面组件
|
||||
│ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ SearchBar │ │ 根据 filters 配置自动生成搜索条件
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ DataTable │ │ 根据 columns 配置渲染 Ant Design Table
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ FormDialog │ │ 根据 form 配置渲染新建/编辑表单
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ ActionBar │ │ 根据 actions 配置渲染操作按钮
|
||||
│ └─────────────┘ │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 页面配置类型
|
||||
|
||||
```typescript
|
||||
interface PluginPageConfig {
|
||||
name: string;
|
||||
path: string;
|
||||
entity: string;
|
||||
type: "crud" | "dashboard" | "custom";
|
||||
icon?: string;
|
||||
menu_group: string;
|
||||
|
||||
// CRUD 配置(可选,不提供时从 schema.entities 自动推导)
|
||||
// columns 未指定时:从 entity 的 fields 生成,type=select 需显式指定 options
|
||||
// form 未指定时:从 entity 的 fields 生成表单,required 字段为必填
|
||||
columns?: ColumnDef[];
|
||||
filters?: FilterDef[];
|
||||
actions?: ActionDef[];
|
||||
form?: FormDef;
|
||||
}
|
||||
|
||||
interface ColumnDef {
|
||||
field: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "date" | "datetime" | "select"
|
||||
| "multiselect" | "currency" | "status" | "link";
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
hidden?: boolean;
|
||||
options?: { label: string; value: string; color?: string }[];
|
||||
}
|
||||
|
||||
interface FormDef {
|
||||
groups?: FormGroup[];
|
||||
fields: FormField[];
|
||||
rules?: ValidationRule[];
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 动态菜单生成
|
||||
|
||||
前端侧边栏从 PluginStore 动态生成菜单项:
|
||||
|
||||
- 基础模块菜单固定(用户、权限、组织、工作流、消息、设置)
|
||||
- 插件菜单按 `menu_group` 分组,动态追加到侧边栏
|
||||
- 菜单数据来自 `/api/v1/plugins/installed` API,启动时加载
|
||||
|
||||
### 8.4 插件 API 路由
|
||||
|
||||
插件的 CRUD API 由宿主自动生成:
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{plugin_id}/{entity} # 列表查询
|
||||
GET /api/v1/plugins/{plugin_id}/{entity}/{id} # 详情
|
||||
POST /api/v1/plugins/{plugin_id}/{entity} # 新建
|
||||
PUT /api/v1/plugins/{plugin_id}/{entity}/{id} # 更新
|
||||
DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} # 删除
|
||||
```
|
||||
|
||||
宿主自动注入 tenant_id、处理分页、乐观锁、软删除。
|
||||
|
||||
### 8.5 自定义页面
|
||||
|
||||
`type: "custom"` 的页面需要额外的渲染指令:
|
||||
|
||||
- 插件 WASM 可以导出 `render_page` 函数,返回 UI 指令 JSON
|
||||
- 宿主前端解析指令并渲染(支持:条件显示、自定义操作、复杂布局)
|
||||
- 复杂交互(如库存盘点)通过事件驱动:前端发送 action → 后端 WASM 处理 → 返回新的 UI 状态
|
||||
|
||||
## 9. 升级后的模块注册系统
|
||||
|
||||
### 9.1 ErpModule trait v2
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ErpModule: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
|
||||
fn dependencies(&self) -> Vec<&str> { vec![] }
|
||||
fn module_type(&self) -> ModuleType;
|
||||
|
||||
// 生命周期
|
||||
async fn on_startup(&self, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> AppResult<ModuleHealth> {
|
||||
Ok(ModuleHealth { status: "ok".into(), details: None })
|
||||
}
|
||||
|
||||
// 路由
|
||||
fn public_routes(&self) -> Option<Router> { None }
|
||||
fn protected_routes(&self) -> Option<Router> { None }
|
||||
|
||||
// 数据库
|
||||
fn migrations(&self) -> Vec<Box<dyn MigrationTrait>> { vec![] }
|
||||
|
||||
// 事件
|
||||
fn register_event_handlers(&self, bus: &EventBus) {}
|
||||
|
||||
// 租户
|
||||
async fn on_tenant_created(&self, tenant_id: Uuid, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_tenant_deleted(&self, tenant_id: Uuid, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
|
||||
// 配置
|
||||
fn config_schema(&self) -> Option<serde_json::Value> { None }
|
||||
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
pub enum ModuleType { Native, Wasm }
|
||||
|
||||
pub struct ModuleHealth {
|
||||
pub status: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ModuleContext {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub config: Arc<AppConfig>,
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 ModuleRegistry v2
|
||||
|
||||
```rust
|
||||
pub struct ModuleRegistry {
|
||||
modules: Arc<Vec<Arc<dyn ErpModule>>>,
|
||||
wasm_runtime: Arc<WasmPluginRuntime>,
|
||||
index: Arc<HashMap<String, usize>>,
|
||||
}
|
||||
|
||||
impl ModuleRegistry {
|
||||
pub fn new() -> Self;
|
||||
|
||||
// 注册 Rust 原生模块
|
||||
pub fn register(self, module: impl ErpModule + 'static) -> Self;
|
||||
|
||||
// 从数据库加载 WASM 插件
|
||||
pub async fn load_wasm_plugins(&mut self, db: &DatabaseConnection) -> AppResult<()>;
|
||||
|
||||
// 按依赖顺序启动所有模块
|
||||
pub async fn startup_all(&self, ctx: &ModuleContext) -> AppResult<()>;
|
||||
|
||||
// 聚合健康状态
|
||||
pub async fn health_check_all(&self) -> HashMap<String, ModuleHealth>;
|
||||
|
||||
// 自动收集所有路由
|
||||
pub fn build_routes(&self) -> (Router, Router);
|
||||
|
||||
// 自动收集所有迁移
|
||||
pub fn collect_migrations(&self) -> Vec<Box<dyn MigrationTrait>>;
|
||||
|
||||
// 拓扑排序(基于 dependencies)
|
||||
fn topological_sort(&self) -> AppResult<Vec<Arc<dyn ErpModule>>>;
|
||||
|
||||
// 按 ID 查找模块
|
||||
pub fn get_module(&self, id: &str) -> Option<&Arc<dyn ErpModule>>;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 升级后的 main.rs
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 初始化 DB、Config、EventBus ...
|
||||
|
||||
// 1. 注册 Rust 原生模块
|
||||
let mut registry = ModuleRegistry::new()
|
||||
.register(AuthModule::new())
|
||||
.register(ConfigModule::new())
|
||||
.register(WorkflowModule::new())
|
||||
.register(MessageModule::new());
|
||||
|
||||
// 2. 从数据库加载 WASM 插件
|
||||
registry.load_wasm_plugins(&db).await?;
|
||||
|
||||
// 3. 依赖排序 + 启动所有模块
|
||||
let ctx = ModuleContext { db: db.clone(), event_bus: event_bus.clone(), config: config.clone() };
|
||||
registry.startup_all(&ctx).await?;
|
||||
|
||||
// 4. 自动收集路由(无需手动 merge)
|
||||
let (public, protected) = registry.build_routes();
|
||||
|
||||
// 5. 构建 Axum 服务
|
||||
let app = Router::new()
|
||||
.nest("/api/v1", public.merge(protected))
|
||||
.with_state(app_state);
|
||||
|
||||
// 启动服务 ...
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 插件开发体验
|
||||
|
||||
### 10.1 插件项目结构
|
||||
|
||||
```
|
||||
erp-plugin-inventory/
|
||||
├── Cargo.toml # crate 类型为 cdylib (WASM)
|
||||
├── plugin.toml # 插件清单
|
||||
└── src/
|
||||
└── lib.rs # 插件入口
|
||||
```
|
||||
|
||||
### 10.2 插件 Cargo.toml
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-inventory"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.24" # WIT 接口绑定生成
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
### 10.3 插件代码示例
|
||||
|
||||
```rust
|
||||
use wit_bindgen::generate::Guest;
|
||||
|
||||
// 自动生成宿主 API 绑定
|
||||
export!(Plugin);
|
||||
|
||||
struct Plugin;
|
||||
|
||||
impl Guest for Plugin {
|
||||
fn init() -> Result<(), String> {
|
||||
host::log_write("info", "进销存插件初始化完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||||
// 初始化默认商品分类等
|
||||
host::db_insert("inventory_category", br#"{"name": "默认分类"}"#)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||||
match event_type.as_str() {
|
||||
"workflow.task.completed" => {
|
||||
// 采购审批通过,更新采购单状态
|
||||
let data: serde_json::Value = serde_json::from_slice(&payload)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let order_id = data["business_id"].as_str().unwrap();
|
||||
host::db_update("purchase_order", order_id,
|
||||
br#"{"status": "approved"}"#, 1)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 构建与发布
|
||||
|
||||
```bash
|
||||
# 编译为 WASM
|
||||
cargo build --target wasm32-unknown-unknown --release
|
||||
|
||||
# 打包(WASM 二进制 + 清单文件)
|
||||
erp-plugin pack ./target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm \
|
||||
--manifest ./plugin.toml \
|
||||
--output ./erp-inventory-1.0.0.erp-plugin
|
||||
|
||||
# 上传到平台(通过管理后台或 API)
|
||||
curl -X POST /api/v1/admin/plugins/upload \
|
||||
-F "plugin=@./erp-inventory-1.0.0.erp-plugin"
|
||||
```
|
||||
|
||||
## 11. 管理后台 API
|
||||
|
||||
### 11.1 插件管理接口
|
||||
|
||||
```
|
||||
POST /api/v1/admin/plugins/upload # 上传插件包
|
||||
GET /api/v1/admin/plugins # 列出所有插件
|
||||
GET /api/v1/admin/plugins/{plugin_id} # 插件详情
|
||||
POST /api/v1/admin/plugins/{plugin_id}/enable # 启用插件
|
||||
POST /api/v1/admin/plugins/{plugin_id}/disable # 停用插件
|
||||
DELETE /api/v1/admin/plugins/{plugin_id} # 卸载插件
|
||||
GET /api/v1/admin/plugins/{plugin_id}/health # 插件健康检查
|
||||
PUT /api/v1/admin/plugins/{plugin_id}/config # 更新插件配置
|
||||
POST /api/v1/admin/plugins/{plugin_id}/upgrade # 升级插件版本
|
||||
```
|
||||
|
||||
### 11.2 插件数据接口(自动生成)
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{plugin_id}/{entity} # 列表查询
|
||||
GET /api/v1/plugins/{plugin_id}/{entity}/{id} # 详情
|
||||
POST /api/v1/plugins/{plugin_id}/{entity} # 新建
|
||||
PUT /api/v1/plugins/{plugin_id}/{entity}/{id} # 更新
|
||||
DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} # 删除
|
||||
```
|
||||
|
||||
## 12. 实施路径
|
||||
|
||||
### Phase 7: 插件系统核心
|
||||
|
||||
1. **引入 Wasmtime 依赖**,创建 `erp-plugin-runtime` crate
|
||||
2. **定义 WIT 接口文件**,描述宿主-插件合约
|
||||
3. **实现 Host API 层** — db_insert/query/update/delete、event_publish、config_get 等
|
||||
4. **实现插件加载器** — 从数据库读取 WASM 二进制、实例化、注册路由
|
||||
5. **升级 ErpModule trait** — 添加 lifecycle hooks、routes、migrations 方法
|
||||
6. **升级 ModuleRegistry** — 拓扑排序、自动路由收集、WASM 插件注册
|
||||
7. **插件管理 API** — 上传、启用、停用、卸载
|
||||
8. **插件数据库表** — plugins、plugin_event_subscriptions + 动态建表逻辑
|
||||
|
||||
### Phase 8: 前端配置驱动 UI
|
||||
|
||||
1. **PluginStore** (Zustand) — 管理已安装插件的页面配置
|
||||
2. **DynamicRouter** — 根据 PluginStore 自动生成 React Router 路由
|
||||
3. **PluginCRUDPage** — 通用 CRUD 渲染引擎(表格 + 搜索 + 表单 + 操作)
|
||||
4. **动态菜单** — 从 PluginStore 生成侧边栏菜单
|
||||
5. **插件管理页面** — 上传、启用/停用、配置的管理后台
|
||||
|
||||
### Phase 9: 第一个行业插件(进销存)
|
||||
|
||||
1. 创建 `erp-plugin-inventory` 作为参考实现
|
||||
2. 实现商品、采购、库存管理的核心业务逻辑
|
||||
3. 配置驱动页面覆盖 80% 的 CRUD 场景
|
||||
4. 验证端到端流程:安装 → 启用 → 使用 → 停用 → 卸载
|
||||
|
||||
## 13. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|----------|
|
||||
| WASM 插件性能不足 | 低 | 高 | 性能基准测试,关键路径保留 Rust 原生 |
|
||||
| 插件安全问题 | 中 | 高 | 沙箱隔离 + 最小权限 + 审计日志 |
|
||||
| 配置驱动 UI 覆盖不足 | 中 | 中 | 保留 custom 页面类型作为兜底 |
|
||||
| 插件间依赖冲突 | 中 | 中 | 拓扑排序 + 版本约束 + 冲突检测 |
|
||||
| Wasmtime 版本兼容性 | 低 | 中 | 锁定 Wasmtime 大版本,CI 验证 |
|
||||
|
||||
## 附录 A: ErpModule Trait 迁移策略
|
||||
|
||||
### A.1 向后兼容原则
|
||||
|
||||
`ErpModule` trait v2 的所有新增方法均提供**默认实现(no-op)**,确保现有四个模块(AuthModule、ConfigModule、WorkflowModule、MessageModule)无需修改即可编译通过。
|
||||
|
||||
### A.2 迁移清单
|
||||
|
||||
| 现有方法 | v2 变化 | 迁移操作 |
|
||||
|----------|---------|----------|
|
||||
| `fn name(&self) -> &str` | 保留不变,新增 `fn id()` 返回相同值 | 在各模块 impl 中添加 `fn id()` |
|
||||
| `fn version()` | 保留不变 | 无需改动 |
|
||||
| `fn dependencies()` | 保留不变 | 无需改动 |
|
||||
| `fn register_event_handlers()` | 签名不变 | 无需改动 |
|
||||
| `fn on_tenant_created(tenant_id)` | 签名变为 `on_tenant_created(tenant_id, ctx)` | 更新签名,添加 `ctx` 参数 |
|
||||
| `fn on_tenant_deleted(tenant_id)` | 签名变为 `on_tenant_deleted(tenant_id, ctx)` | 更新签名,添加 `ctx` 参数 |
|
||||
| `fn as_any()` | 保留不变 | 无需改动 |
|
||||
| (新增)`fn module_type()` | 默认返回 `ModuleType::Native` | 无需改动 |
|
||||
| (新增)`fn on_startup()` | 默认 no-op | 可选实现 |
|
||||
| (新增)`fn on_shutdown()` | 默认 no-op | 可选实现 |
|
||||
| (新增)`fn health_check()` | 默认返回 ok | 可选实现 |
|
||||
| (新增)`fn public_routes()` | 默认 None | 将现有关联函数迁移到此方法 |
|
||||
| (新增)`fn protected_routes()` | 默认 None | 将现有关联函数迁移到此方法 |
|
||||
| (新增)`fn migrations()` | 默认空 vec | 可选实现 |
|
||||
| (新增)`fn config_schema()` | 默认 None | 可选实现 |
|
||||
|
||||
### A.3 迁移后的 main.rs 变化
|
||||
|
||||
迁移后,main.rs 从手动路由合并变为自动收集:
|
||||
|
||||
```rust
|
||||
// 迁移前(手动)
|
||||
let protected_routes = erp_auth::AuthModule::protected_routes()
|
||||
.merge(erp_config::ConfigModule::protected_routes())
|
||||
.merge(erp_workflow::WorkflowModule::protected_routes())
|
||||
.merge(erp_message::MessageModule::protected_routes());
|
||||
|
||||
// 迁移后(自动)
|
||||
let (public, protected) = registry.build_routes();
|
||||
```
|
||||
|
||||
## 附录 B: EventBus 类型化订阅扩展
|
||||
|
||||
### B.1 现有 EventBus 扩展
|
||||
|
||||
现有的 `EventBus`(`erp-core/src/events.rs`)只有 `subscribe()` 方法返回全部事件的 `Receiver`。需要添加类型化过滤订阅:
|
||||
|
||||
```rust
|
||||
impl EventBus {
|
||||
/// 订阅特定事件类型
|
||||
/// 内部使用 mpmc 通道,为每个事件类型维护独立的分发器
|
||||
pub fn subscribe_filtered(
|
||||
&self,
|
||||
event_type: &str,
|
||||
handler: Box<dyn Fn(DomainEvent) + Send + Sync>,
|
||||
) -> SubscriptionHandle {
|
||||
// 在内部 HashMap<String, Vec<Handler>> 中注册
|
||||
// publish() 时根据 event_type 分发到匹配的 handler
|
||||
}
|
||||
|
||||
/// 取消订阅(用于插件停用时清理)
|
||||
pub fn unsubscribe(&self, handle: SubscriptionHandle) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### B.2 插件事件处理器包装
|
||||
|
||||
```rust
|
||||
struct PluginEventHandler {
|
||||
plugin_id: String,
|
||||
handler_fn: Box<dyn Fn(DomainEvent) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl PluginEventHandler {
|
||||
fn handle(&self, event: DomainEvent) {
|
||||
// 捕获 panic,防止插件崩溃影响宿主
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
(self.handler_fn)(event)
|
||||
});
|
||||
if let Err(_) = result {
|
||||
tracing::error!("插件 {} 事件处理器崩溃", self.plugin_id);
|
||||
// 通知 PluginManager 标记插件为 error 状态
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 C: 管理后台 API 权限控制
|
||||
|
||||
### C.1 权限模型
|
||||
|
||||
| API 端点 | 所需权限 | 角色范围 |
|
||||
|----------|---------|----------|
|
||||
| `POST /admin/plugins/upload` | `plugin:admin` | 仅平台超级管理员 |
|
||||
| `POST /admin/plugins/{id}/enable` | `plugin:manage` | 平台管理员或租户管理员(仅限自己租户的插件) |
|
||||
| `POST /admin/plugins/{id}/disable` | `plugin:manage` | 平台管理员或租户管理员 |
|
||||
| `DELETE /admin/plugins/{id}` | `plugin:manage` | 租户管理员(软删除) |
|
||||
| `DELETE /admin/plugins/{id}/purge` | `plugin:admin` | 仅平台超级管理员 |
|
||||
| `GET /admin/plugins` | `plugin:view` | 租户管理员(仅看到自己租户的插件) |
|
||||
| `PUT /admin/plugins/{id}/config` | `plugin:configure` | 租户管理员 |
|
||||
| `GET /admin/plugins/{id}/health` | `plugin:view` | 租户管理员 |
|
||||
|
||||
### C.2 租户隔离
|
||||
|
||||
- 插件管理 API 自动注入 `tenant_id` 过滤(从 JWT 中提取)
|
||||
- 平台超级管理员可以通过 `/admin/platform/plugins` 查看所有租户的插件
|
||||
- 租户管理员只能管理自己租户安装的插件
|
||||
- 插件上传为平台级操作(所有租户共享同一个 WASM 二进制),但启用/配置为租户级操作
|
||||
|
||||
## 附录 D: WIT 接口定义
|
||||
|
||||
### D.1 插件接口 (`plugin.wit`)
|
||||
|
||||
```wit
|
||||
package erp:plugin;
|
||||
|
||||
interface host {
|
||||
/// 数据库操作
|
||||
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: string, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
|
||||
db-delete: func(entity: string, id: string) -> result<_, string>;
|
||||
db-aggregate: func(entity: string, query: list<u8>) -> result<list<u8>, 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>;
|
||||
}
|
||||
|
||||
interface plugin {
|
||||
/// 插件初始化(加载时调用一次)
|
||||
init: func() -> result<_, string>;
|
||||
|
||||
/// 租户创建时调用
|
||||
on-tenant-created: func(tenant-id: string) -> result<_, string>;
|
||||
|
||||
/// 处理订阅的事件
|
||||
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
|
||||
/// 自定义页面渲染(仅 type=custom 页面)
|
||||
render-page: func(page-path: string, params: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 自定义页面操作处理
|
||||
handle-action: func(page-path: string, action: string, data: list<u8>) -> result<list<u8>, string>;
|
||||
}
|
||||
|
||||
world plugin-world {
|
||||
import host;
|
||||
export plugin;
|
||||
}
|
||||
```
|
||||
|
||||
### D.2 使用方式
|
||||
|
||||
插件开发者使用 `wit-bindgen` 生成绑定代码:
|
||||
|
||||
```bash
|
||||
# 生成 Rust 插件绑定
|
||||
wit-bindgen rust ./plugin.wit --out-dir ./src/generated
|
||||
```
|
||||
|
||||
宿主使用 `wasmtime` 的 `bindgen!` 宏生成调用端代码:
|
||||
|
||||
```rust
|
||||
// 在 erp-plugin-runtime crate 中
|
||||
wasmtime::component::bindgen!({
|
||||
path: "./plugin.wit",
|
||||
world: "plugin-world",
|
||||
async: true,
|
||||
});
|
||||
```
|
||||
|
||||
## 附录 E: 插件崩溃恢复策略
|
||||
|
||||
### E.1 崩溃检测与恢复
|
||||
|
||||
| 场景 | 检测方式 | 恢复策略 |
|
||||
|------|---------|----------|
|
||||
| WASM 执行 panic | `catch_unwind` 捕获 | 记录错误日志,该请求返回 500,插件继续运行 |
|
||||
| 插件 init() 失败 | 返回 Err | 标记插件为 `error` 状态,不加载 |
|
||||
| 事件处理器崩溃 | `catch_unwind` 捕获 | 记录错误日志,事件丢弃(不重试) |
|
||||
| 连续崩溃(>5次/分钟) | 计数器检测 | 自动停用插件,标记 `error`,通知管理员 |
|
||||
| 服务重启 | 启动流程 | 重新加载所有 `enabled` 状态的插件 |
|
||||
|
||||
### E.2 僵尸状态处理
|
||||
|
||||
插件在数据库中为 `enabled` 但实际未运行的情况:
|
||||
|
||||
1. 服务启动时,所有 `enabled` 插件尝试加载
|
||||
2. 加载失败的插件自动标记为 `error`,`error_message` 记录原因
|
||||
3. 管理后台显示 `error` 状态的插件,提供"重试"按钮
|
||||
4. 重试成功后恢复为 `enabled`,重试失败保持 `error`
|
||||
|
||||
### E.3 插件健康检查
|
||||
|
||||
```rust
|
||||
/// 定期健康检查(每 60 秒)
|
||||
async fn health_check_loop(registry: &ModuleRegistry) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let results = registry.health_check_all().await;
|
||||
for (id, health) in results {
|
||||
if health.status != "ok" {
|
||||
tracing::warn!("模块 {} 健康检查异常: {:?}", id, health.details);
|
||||
// 通知管理后台
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 F: Crate 依赖图更新
|
||||
|
||||
```
|
||||
erp-core (无业务依赖)
|
||||
erp-common (无业务依赖)
|
||||
↑
|
||||
erp-auth (→ core)
|
||||
erp-config (→ core)
|
||||
erp-workflow (→ core)
|
||||
erp-message (→ core)
|
||||
erp-plugin-runtime (→ core, wasmtime) ← 新增
|
||||
↑
|
||||
erp-server (→ 所有 crate,组装入口)
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `erp-plugin-runtime` 依赖 `erp-core`(使用 EventBus、ErpModule trait、AppError)
|
||||
- `erp-plugin-runtime` 依赖 `wasmtime`(WASM 运行时)
|
||||
- `erp-plugin-runtime` 不依赖任何业务 crate(auth/config/workflow/message)
|
||||
- `erp-server` 在组装时引入 `erp-plugin-runtime`
|
||||
Reference in New Issue
Block a user