# 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` — 插件菜单 store - `crates/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` 枚举: ```rust pub enum PluginFieldType { String, Integer, Float, Boolean, Date, DateTime, Json, Uuid, Decimal, } ``` 没有 `Select` 或 `Enum` 类型。 ### 解决方案:不需要扩展 manifest schema 当前 `PluginField` 已经有两个关键字段(`manifest.rs` 第 50-61 行): ```rust pub struct PluginField { pub name: String, pub field_type: PluginFieldType, pub ui_widget: Option, // <-- 关键:可指定前端渲染组件 pub options: Option>, // <-- 关键:下拉选项列表 ... } ``` `ui_widget` 和 `options` 已经提供了 Select 的完整支持。前端 `PluginCRUDPage.tsx` 第 178-185 行已经处理了 `select` widget: ```typescript case 'select': return ( ); ``` **结论:CRM 的 select 字段不需要扩展 manifest。** 在 plugin.toml 中声明方式: ```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` 的实现: ```rust fn db_query(&mut self, entity: String, _filter: Vec, _pagination: Vec) -> Result, 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 连接。 可行的改造方案: 1. `db_query` 仍然走预填充模式,但在 `execute_wasm` 前根据 WASM 函数类型智能预填充(不现实,因为不知道插件会查什么) 2. 改为同步查询模式:在 `HostState` 中持有同步的 DB 连接(需要 `blocking_spawn` 内部再 spawn async task) 3. **最佳方案**:前端直接调用 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`。 **需要做的改造:** 1. **后端**:在 `PluginDataListParams` 中增加 `filter` 参数(JSON 格式,如 `{"customer_id": "xxx"}`) 2. **后端**:在 `PluginDataService::list` 中解析 filter,构建带 WHERE 条件的 SQL 3. **前端**:`PluginCRUDPage` 接收 URL 参数如 `?filter[customer_id]=xxx`,传给 API `dynamic_table.rs` 需要新增一个 `build_filtered_query_sql` 方法: ```rust 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) ``` 使用 PostgreSQL 的 JSONB 查询操作符:`data->>'customer_id' = $N`。 **结论:关联查询需要扩展现有 API,但改造量不大。** 主要是在 REST API 层增加 filter 支持,不需要改 WASM 运行时。这是中等工作量的改造。 --- ## 4. 唯一性约束 ### customer.code 唯一性 `dynamic_table.rs` 第 67-87 行,建表时会为 `unique: true` 的字段创建索引: ```rust 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,没有做唯一性检查: ```rust 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 处理。 ### 需要修复 1. **建表时**:`field.unique` 应创建 `CREATE UNIQUE INDEX` 2. **INSERT 时**:需要检查 unique 字段的值是否已存在(或使用 `ON CONFLICT`) 3. **REST API 层**:`PluginDataService::create` 也需要同样的检查 修复方案(`dynamic_table.rs` 建表部分): ```rust 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 行: ```rust pub struct PluginUi { pub pages: Vec, } pub struct PluginPage { pub route: String, pub entity: String, pub display_name: String, pub icon: String, pub menu_group: Option, } ``` ### 需要扩展为 ```rust pub struct PluginPage { pub route: String, pub entity: String, pub display_name: String, pub icon: String, pub menu_group: Option, // 新增 pub page_type: Option, // "crud" | "tree" | "graph" | "timeline" | "tabs" | "dashboard" pub tabs: Option>, // tabs 嵌套 pub field_mappings: Option>, // 字段映射配置 pub filters: Option>, // 过滤器配置 } pub struct PluginTab { pub label: String, pub entity: String, pub page_type: Option, pub filters: Option>, } pub struct PluginFilter { pub field: String, pub source: String, // "url_param" | "parent_entity" | "fixed" pub value: Option, } ``` ### 影响范围 1. **manifest.rs**:扩展 `PluginPage` 结构体 — 改动量小,只新增字段 2. **service.rs**:`install` 方法中需要将新的 manifest 数据存入 `plugin_entity.schema_json` — 无需改动(已序列化完整 entity 定义) 3. **前端 plugin store**:`plugin.ts` 的 `PluginMenuItem` 需要增加 `pageType` 字段 — 改动量小 4. **前端路由**:`App.tsx` 需要根据 `page_type` 渲染不同组件 — 中等改动 5. **前端 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 行: ```rust fn check_permission(&mut self, permission: String) -> Result { Ok(self.permissions.contains(&permission)) } ``` 这个 API 检查的是当前用户的权限列表。`permissions` 来自 `ExecutionContext.permissions`(`engine.rs` 第 68 行),是外部传入的用户权限列表。 **关键问题:CRM 的 8 个权限码在数据库中不存在。** 当前权限系统的流程: 1. 权限定义在 `permissions` 表中(通过 migration seed 或 admin API 创建) 2. 通过 `role_permissions` 表分配给角色 3. 用户登录时,JWT 中间件从角色关联加载权限列表 CRM 插件的 `crm.customer.list` 等权限需要: 1. 在插件安装时,动态 INSERT 到 `permissions` 表 2. 分配给适当角色 3. 这样用户请求时 JWT 中间件才能加载这些权限 ### 需要的改造 1. **安装时自动注册权限**:`service.rs` 的 `install` 方法需要遍历 manifest 中的 `permissions` 列表,INSERT 到 `permissions` 表 2. **REST API 权限映射**:`data_handler.rs` 的权限检查需要从固定 `plugin.list`/`plugin.admin` 改为动态检查——基于 manifest 中声明的权限码 3. **卸载时清理权限**:`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 天) 1. **修复唯一索引**:`dynamic_table.rs` 中 `field.unique` 创建 `CREATE UNIQUE INDEX` 2. **权限注册**:`service.rs` 的 `install` 方法将 manifest.permissions 写入 permissions 表 3. **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 插件系统上的实现**整体可行**,但有几个前提条件需要先满足: 1. **必须修复**:唯一索引 bug、权限注册缺失、REST API 过滤能力缺失 2. **核心路径**:CRM 的数据操作通过 REST API 而非 WASM 内的 db-query 完成(这是正确的架构选择) 3. **WASM 角色定位**:WASM 负责生命周期钩子(init/on_tenant_created/handle_event),不负责复杂查询和聚合 4. **前端是主要工作量**:5 种新页面类型中,graph 和 dashboard 开发量最大,建议分两期交付 最大的技术风险不在 WASM 运行时本身(它已被验证),而在于周边配套设施(权限、过滤、前端组件)的完善程度。