22 KiB
CRM WASM 插件实现可行性审查报告
审查范围
基于对以下核心代码的完整审查:
crates/erp-plugin/wit/plugin.wit— WIT 接口定义crates/erp-plugin/src/host.rs— Host API 实现crates/erp-plugin/src/engine.rs— WASM 引擎(Fuel 限制、执行流程)crates/erp-plugin/src/manifest.rs— 插件清单结构crates/erp-plugin/src/dynamic_table.rs— 动态表管理(建表、CRUD SQL)crates/erp-plugin/src/data_service.rs— 数据服务层crates/erp-plugin/src/data_handler.rs— API handler(权限检查)crates/erp-plugin/src/service.rs— 插件生命周期管理crates/erp-plugin/src/module.rs— 路由注册apps/web/src/pages/PluginCRUDPage.tsx— 前端通用 CRUD 页面apps/web/src/stores/plugin.ts— 插件菜单 storecrates/erp-server/migration/src/m20260417_000034_seed_plugin_permissions.rs— 权限种子数据
1. WASM 插件逻辑复杂度
init() — 需要做什么
CRM 插件的 init() 应当做以下事情:
- 日志记录初始化信息
- 可选:通过
config_get读取 CRM 相关配置 - 可选:通过
db_insert创建默认字典数据(如客户类型、级别、来源等默认选项)
复杂度:低。 主要是 log_write + 若干 db_insert 插入初始配置数据。涉及的逻辑只是 JSON 构造和 Host API 调用。1000 万 Fuel 绰绰有余。
on_tenant_created() — 需要做什么
为新建租户创建 CRM 默认数据:
- 插入默认客户类型字典(潜在客户、意向客户、成交客户、流失客户)
- 插入默认行业分类
- 插入默认客户来源选项
- 可选:创建默认客户级别(A/B/C/D)
复杂度:低-中。 纯数据初始化,大概需要 10-20 次 db_insert 调用。每次 db_insert 只是构造 JSON + 调用 Host API 入队 pending_ops,不涉及复杂计算。Fuel 消耗估算:每次 Host API 调用约 5000-10000 fuel,20 次调用约 20 万 fuel,远低于 1000 万限制。
handle_event() — 需要做什么
取决于 CRM 订阅了哪些事件。典型场景:
- 订阅
user.created:自动为客户经理创建关联记录 - 订阅
workflow.task.completed:处理客户审批流程完成事件 - 订阅自定义 CRM 事件:如
crm.customer.created触发欢迎邮件模板创建
复杂度:中。 需要模式匹配 event_type,解析 JSON payload,可能需要 db_query 查询关联数据,然后 db_insert/db_update 写入结果。关键限制是 db_query 只能访问预填充的数据。
Fuel 限制评估
当前默认 Fuel 为 1000 万(engine.rs 第 32 行)。对于 CRM 插件的三种场景:
| 场景 | 预估 Fuel | 是否足够 |
|---|---|---|
| init() | 5-20 万 | 充裕 |
| on_tenant_created() | 10-30 万 | 充裕 |
| handle_event() | 5-50 万 | 充裕 |
结论:Fuel 限制不是瓶颈。 1000 万 fuel 对 CRM 的业务逻辑(JSON 解析 + 若干 Host API 调用)绰绰有余。真正的复杂业务逻辑(报表聚合、批量操作)应放在 Host 端而不是 WASM 内部。
2. 动态表 Schema 声明 — Select 类型
当前支持
manifest.rs 第 66-76 行定义了 PluginFieldType 枚举:
pub enum PluginFieldType {
String, Integer, Float, Boolean, Date, DateTime, Json, Uuid, Decimal,
}
没有 Select 或 Enum 类型。
解决方案:不需要扩展 manifest schema
当前 PluginField 已经有两个关键字段(manifest.rs 第 50-61 行):
pub struct PluginField {
pub name: String,
pub field_type: PluginFieldType,
pub ui_widget: Option<String>, // <-- 关键:可指定前端渲染组件
pub options: Option<Vec<serde_json::Value>>, // <-- 关键:下拉选项列表
...
}
ui_widget 和 options 已经提供了 Select 的完整支持。前端 PluginCRUDPage.tsx 第 178-185 行已经处理了 select widget:
case 'select':
return (
<Select>
{(field.options || []).map((opt) => (
<Select.Option key={String(opt.value)} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
);
结论:CRM 的 select 字段不需要扩展 manifest。 在 plugin.toml 中声明方式:
[[schema.entities.fields]]
name = "customer_type"
field_type = "string"
ui_widget = "select"
options = [
{ label = "潜在客户", value = "potential" },
{ label = "意向客户", value = "intention" },
{ label = "成交客户", value = "closed" },
{ label = "流失客户", value = "churned" },
]
底层存储仍为 string(JSONB 的 data->>'customer_type'),数据库层不需要 enum 约束。
3. 关联实体查询
当前 db-query 的限制
查看 host.rs 第 99-109 行,db_query 的实现:
fn db_query(&mut self, entity: String, _filter: Vec<u8>, _pagination: Vec<u8>) -> Result<Vec<u8>, String> {
self.query_results
.get(&entity)
.cloned()
.ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity))
}
关键发现:当前 db-query 完全依赖预填充。 filter 和 pagination 参数被忽略(_filter, _pagination)。查询结果在 WASM 执行前由 Host 预填充到 HostState.query_results 中。
但 engine.rs 的 execute_wasm 方法(第 444-522 行)中并没有看到预填充查询结果的逻辑。HostState::new 只初始化了空的 query_results: HashMap::new()。
CRM 关联查询场景
"联系人列表按 customer_id 过滤" 的需求有两种实现路径:
路径 A:扩展 Host API 的 db-query(推荐)
需要修改 engine.rs 的 execute_wasm 方法,在 WASM 执行前解析 filter 参数,执行真实 SQL 查询,将结果预填充。或者更直接的方案——让 db_query 在调用时实时执行查询,而不是依赖预填充。
这意味着需要在 HostState 中持有 DatabaseConnection 的引用,或者将 db_query 改为延迟执行模式。但当前架构是 WASM 在 spawn_blocking 中同步执行,无法直接持有异步的 DB 连接。
可行的改造方案:
db_query仍然走预填充模式,但在execute_wasm前根据 WASM 函数类型智能预填充(不现实,因为不知道插件会查什么)- 改为同步查询模式:在
HostState中持有同步的 DB 连接(需要blocking_spawn内部再 spawn async task) - 最佳方案:前端直接调用 REST API 查询关联数据,不走 WASM 的 db-query
路径 B:前端直接按 customer_id 过滤(当前最可行)
CRM 的关联查询(联系人与客户、沟通记录与客户)不需要走 WASM 的 db-query。前端 PluginCRUDPage 已经可以直接调用 GET /api/v1/plugins/{plugin_id}/{entity}?page=1 来获取联系人列表。
但当前 REST API 的 list_plugin_data(data_handler.rs 第 25-57 行)也不支持过滤参数。PluginDataListParams 只有 page, page_size, search。
需要做的改造:
- 后端:在
PluginDataListParams中增加filter参数(JSON 格式,如{"customer_id": "xxx"}) - 后端:在
PluginDataService::list中解析 filter,构建带 WHERE 条件的 SQL - 前端:
PluginCRUDPage接收 URL 参数如?filter[customer_id]=xxx,传给 API
dynamic_table.rs 需要新增一个 build_filtered_query_sql 方法:
pub fn build_filtered_query_sql(
table_name: &str,
tenant_id: Uuid,
filters: &serde_json::Value, // {"customer_id": "xxx", "status": "active"}
limit: u64,
offset: u64,
) -> (String, Vec<Value>)
使用 PostgreSQL 的 JSONB 查询操作符:data->>'customer_id' = $N。
结论:关联查询需要扩展现有 API,但改造量不大。 主要是在 REST API 层增加 filter 支持,不需要改 WASM 运行时。这是中等工作量的改造。
4. 唯一性约束
customer.code 唯一性
dynamic_table.rs 第 67-87 行,建表时会为 unique: true 的字段创建索引:
if field.unique || field.required {
let idx_sql = format!(
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" \
(\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
);
}
但这里只创建了普通索引(CREATE INDEX),不是唯一索引。field.unique 判断只影响了索引名称后缀("uniq" vs "idx"),索引类型始终是 CREATE INDEX,不是 CREATE UNIQUE INDEX。
Bug:unique 字段没有创建唯一索引。
此外,engine.rs 的 flush_ops 中 PendingOp::Insert(第 543-560 行)执行的是普通 INSERT,没有做唯一性检查:
let (sql, values) = DynamicTableManager::build_insert_sql_with_id(
&table_name, id_uuid, tenant_id, user_id, &parsed_data
);
txn.execute(Statement::from_sql_and_values(...)).await?;
build_insert_sql_with_id(dynamic_table.rs 第 141-162 行)也是普通 INSERT,没有 ON CONFLICT 处理。
需要修复
- 建表时:
field.unique应创建CREATE UNIQUE INDEX - INSERT 时:需要检查 unique 字段的值是否已存在(或使用
ON CONFLICT) - REST API 层:
PluginDataService::create也需要同样的检查
修复方案(dynamic_table.rs 建表部分):
let idx_sql = if field.unique {
format!(
"CREATE UNIQUE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" \
(\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
)
} else {
format!(
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" \
(\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
)
};
INSERT 前需要做存在性检查,或使用事务内 SELECT + INSERT 模式。
结论:唯一性约束是必须修复的 bug,否则 CRM 的 customer.code 无法保证唯一。 改动量小但重要性高。
5. 前端通用组件实现量
5.1 tree(树形页面)
用途:组织架构树、客户层级树(parent_id 关系)
Ant Design 基础组件:Tree / DirectoryTree / TreeSelect
开发量:2-3 天
- Schema 解析:从 manifest 中识别
parent_id字段作为树形关系 - 数据加载:一次性加载全量数据,前端组装树结构
- CRUD 操作:内联新增/编辑/删除节点
- 拖拽排序(可选)
关键依赖:需要 PluginCRUDPage 支持全量加载模式(不分页),当前 data_service.rs 的 list 只支持分页。
5.2 graph(关系图)
用途:客户关系图(customer_relationship 实体)
Ant Design 基础组件:没有内置 graph 组件
需要引入的库:@ant-design/charts(G6 封装)或 reactflow
开发量:5-8 天
- 图数据转换:将 customer + customer_relationship 数据转换为 nodes/edges
- 图布局算法:力导向布局 / 层次布局
- 交互:节点点击查看详情、拖拽、缩放
- 工具栏:放大/缩小/导出
技术风险:高。图可视化是这 5 种页面类型中最复杂的。需要考虑性能(100+ 节点时的渲染)。
5.3 timeline(时间线)
用途:沟通记录时间线、客户跟进历史
Ant Design 基础组件:Timeline / Steps
开发量:1-2 天
- 数据排序:按
occurred_at排序 - 渲染:不同
type(电话/邮件/会议)显示不同图标和颜色 - 交互:点击展开详情
最简单的页面类型,Ant Design Timeline 直接可用。
5.4 tabs(标签页嵌套)
用途:客户详情页包含多个标签(基本信息/联系人/沟通记录/标签/关系)
Ant Design 基础组件:Tabs
开发量:3-5 天
- Manifest 扩展:定义 tabs 嵌套结构
- 组件组合:每个 tab 内部是一个子页面(可能是 crud/timeline/graph)
- 数据关联:子页面需要接收父实体的 ID 作为过滤条件
- 路由:需要处理嵌套路由或状态切换
核心挑战:tabs 嵌套需要 manifest 结构支持 children 或 tabs 字段,并且在运行时动态组合不同类型的页面组件。
5.5 dashboard(仪表盘)
用途:CRM 概览(客户总数、本月新增、跟进中、转化率等)
Ant Design 基础组件:Statistic / Card / @ant-design/charts
开发量:5-7 天
- 统计卡片:数值展示 + 趋势箭头
- 图表:柱状图/折线图/饼图(需要聚合查询 API)
- 筛选器:时间范围、客户类型等
- 实时更新(可选)
关键依赖:需要后端提供聚合查询 API(COUNT/GROUP BY),当前 db_query 不支持聚合。@ant-design/charts 需要作为新依赖安装。
汇总
| 页面类型 | 开发量 | 技术风险 | Ant Design 支持 |
|---|---|---|---|
| tree | 2-3 天 | 低 | Timeline/Tree 组件直接可用 |
| graph | 5-8 天 | 高 | 需要引入第三方库 |
| timeline | 1-2 天 | 低 | Timeline 组件直接可用 |
| tabs | 3-5 天 | 中 | Tabs 可用,但需要组合其他页面类型 |
| dashboard | 5-7 天 | 中 | Statistic/Card 可用,图表需第三方库 |
总计:16-25 天(约 3-5 周)的前端开发量。建议分两期:
- 第一期(1-2 周):timeline + tree + tabs(CRM 核心需要)
- 第二期(2-3 周):dashboard + graph(锦上添花)
6. manifest.ui.pages 解析
当前结构
manifest.rs 第 94-109 行:
pub struct PluginUi {
pub pages: Vec<PluginPage>,
}
pub struct PluginPage {
pub route: String,
pub entity: String,
pub display_name: String,
pub icon: String,
pub menu_group: Option<String>,
}
需要扩展为
pub struct PluginPage {
pub route: String,
pub entity: String,
pub display_name: String,
pub icon: String,
pub menu_group: Option<String>,
// 新增
pub page_type: Option<String>, // "crud" | "tree" | "graph" | "timeline" | "tabs" | "dashboard"
pub tabs: Option<Vec<PluginTab>>, // tabs 嵌套
pub field_mappings: Option<HashMap<String, String>>, // 字段映射配置
pub filters: Option<Vec<PluginFilter>>, // 过滤器配置
}
pub struct PluginTab {
pub label: String,
pub entity: String,
pub page_type: Option<String>,
pub filters: Option<Vec<PluginFilter>>,
}
pub struct PluginFilter {
pub field: String,
pub source: String, // "url_param" | "parent_entity" | "fixed"
pub value: Option<String>,
}
影响范围
- manifest.rs:扩展
PluginPage结构体 — 改动量小,只新增字段 - service.rs:
install方法中需要将新的 manifest 数据存入plugin_entity.schema_json— 无需改动(已序列化完整 entity 定义) - 前端 plugin store:
plugin.ts的PluginMenuItem需要增加pageType字段 — 改动量小 - 前端路由:
App.tsx需要根据page_type渲染不同组件 — 中等改动 - 前端 PluginCRUDPage:需要重构为通用页面路由器,根据 page_type 分发到不同组件
结论:manifest 结构扩展的改动量不大(Rust 端约 30 行),但前端需要重构路由分发逻辑。对现有代码的影响是可控的,因为都是新增字段(Option<>),不破坏现有 manifest 解析。
7. 权限与数据隔离
当前权限模型
data_handler.rs 中的权限检查(第 35、80、116、143、181 行):
- 列表/详情:
require_permission(&ctx, "plugin.list") - 创建/更新/删除:
require_permission(&ctx, "plugin.admin")
所有插件数据共用 plugin.list 和 plugin.admin 两个权限码。
m20260417_000034_seed_plugin_permissions.rs 也只种子了这两个权限。
CRM 需要 8 个权限码
CRM 设计的权限码如 crm.customer.list、crm.customer.create 等。这些与当前的 plugin.list/plugin.admin 是两套完全不同的权限体系。
check-permission Host API
host.rs 第 167-169 行:
fn check_permission(&mut self, permission: String) -> Result<bool, String> {
Ok(self.permissions.contains(&permission))
}
这个 API 检查的是当前用户的权限列表。permissions 来自 ExecutionContext.permissions(engine.rs 第 68 行),是外部传入的用户权限列表。
关键问题:CRM 的 8 个权限码在数据库中不存在。
当前权限系统的流程:
- 权限定义在
permissions表中(通过 migration seed 或 admin API 创建) - 通过
role_permissions表分配给角色 - 用户登录时,JWT 中间件从角色关联加载权限列表
CRM 插件的 crm.customer.list 等权限需要:
- 在插件安装时,动态 INSERT 到
permissions表 - 分配给适当角色
- 这样用户请求时 JWT 中间件才能加载这些权限
需要的改造
- 安装时自动注册权限:
service.rs的install方法需要遍历 manifest 中的permissions列表,INSERT 到permissions表 - REST API 权限映射:
data_handler.rs的权限检查需要从固定plugin.list/plugin.admin改为动态检查——基于 manifest 中声明的权限码 - 卸载时清理权限:
uninstall方法需要清理permissions表和role_permissions表中的插件权限
当前 manifest 已支持 permissions 声明(manifest.rs 第 112-118 行的 PluginPermission),但 service.rs 的 install 方法没有将其写入 permissions 表。
结论:权限系统需要中等改造。 核心改动点在 service.rs 的 install/uninstall 方法中增加权限 CRUD,以及 data_handler.rs 中从固定权限改为动态权限检查。
8. 整体实施风险评估
高风险
R1:db-query Host API 不可用(风险等级:高)
问题:当前 db_query 依赖预填充,但 execute_wasm 中没有预填充逻辑。CRM 插件在 WASM 内部无法执行关联查询。
影响:WASM 内的 handle_event 无法查询关联数据。例如,收到 workflow.task.completed 事件后,无法查询关联的客户记录。
缓解方案:
- 短期:CRM 的关联查询全部走前端 REST API,WASM 内只做简单的
db_insert/db_update - 长期:改造
db_query为实时查询模式(需要在spawn_blocking中支持异步 DB 调用)
建议:CRM 的复杂查询(关联、聚合)不应在 WASM 内完成。WASM 插件的 handle_event 应限于简单的状态变更,复杂查询由前端直接调用 REST API。
R2:唯一索引 Bug(风险等级:高)
问题:dynamic_table.rs 的 unique 字段只创建了普通索引,不是唯一索引。INSERT 时也没有冲突检查。
影响:customer.code 无法保证唯一性,可能插入重复数据。
修复量:约 20 行代码改动。
R3:插件权限未注册到数据库(风险等级:高)
问题:manifest 声明了 permissions,但 install 时没有写入 permissions 表。
影响:CRM 的 8 个权限码在数据库中不存在,check-permission 永远返回 false,JWT 中间件也无法加载这些权限。
修复量:约 50 行代码改动(install/uninstall 方法增加权限 CRUD)。
中风险
R4:REST API 不支持过滤查询(风险等级:中)
问题:PluginDataListParams 只有分页参数,不支持字段过滤。CRM 需要按 customer_id 过滤联系人等场景。
修复量:约 80 行代码改动(后端 filter 解析 + SQL 构建 + 前端传参)。
R5:前端 tabs 嵌套实现复杂度(风险等级:中)
问题:tabs 页面类型需要支持动态组合不同子页面类型,且子页面需要从父页面获取过滤参数。这涉及组件设计模式的选择。
缓解方案:先实现基本的 tabs + crud 组合,graph/timeline 作为子页面类型后续迭代。
R6:前端页面类型路由分发(风险等级:中)
问题:当前 App.tsx 只有一个 PluginCRUDPage 路由。需要根据 manifest 中的 page_type 动态选择渲染组件。
修复量:约 100 行代码改动(新增路由分发组件 + 各页面类型组件)。
低风险
R7:Fuel 限制(风险等级:低)
1000 万 fuel 对 CRM 插件绰绰有余。无需改动。
R8:manifest 扩展(风险等级:低)
新增的 page_type、tabs、filters 字段都是 Option<>,不破坏现有解析。改动量约 30 行。
实施建议
必须先修复的 3 个问题(1-2 天)
- 修复唯一索引:
dynamic_table.rs中field.unique创建CREATE UNIQUE INDEX - 权限注册:
service.rs的install方法将 manifest.permissions 写入 permissions 表 - REST API 过滤:
PluginDataListParams增加 filter 参数,build_query_sql支持条件过滤
分期实施计划
第一期:最小可用 CRM(2-3 周)
- 5 个 JSONB 实体(customer/contact/communication/customer_tag/customer_relationship)
- plugin.toml manifest 定义
- WASM 插件 Rust crate(init + on_tenant_created + handle_event 基础逻辑)
- 前端 CRUD 页面(复用已有 PluginCRUDPage)
- 前端 tabs 页面类型(客户详情页的基本信息+联系人+沟通记录)
- 前端 timeline 页面类型(沟通记录时间线)
- REST API filter 支持
第二期:增强功能(2-3 周)
- 前端 tree 页面类型(客户层级树)
- 前端 dashboard 页面类型(CRM 概览统计)
- 后端聚合查询 API(COUNT/GROUP BY)
- graph 页面类型(客户关系图)
- 高级搜索/筛选
第三期:优化打磨(1-2 周)
- WASM 内部 db-query 实时查询改造
- 批量操作支持
- 数据导入导出
- 性能优化(大量客户数据场景)
总结
CRM 插件在当前 WASM 插件系统上的实现整体可行,但有几个前提条件需要先满足:
- 必须修复:唯一索引 bug、权限注册缺失、REST API 过滤能力缺失
- 核心路径:CRM 的数据操作通过 REST API 而非 WASM 内的 db-query 完成(这是正确的架构选择)
- WASM 角色定位:WASM 负责生命周期钩子(init/on_tenant_created/handle_event),不负责复杂查询和聚合
- 前端是主要工作量:5 种新页面类型中,graph 和 dashboard 开发量最大,建议分两期交付
最大的技术风险不在 WASM 运行时本身(它已被验证),而在于周边配套设施(权限、过滤、前端组件)的完善程度。