fix(用户管理): 修复用户列表页面加载失败问题
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

修复用户列表页面加载失败导致测试超时的问题,确保页面元素正确渲染
This commit is contained in:
iven
2026-04-19 08:46:28 +08:00
parent 0ee9d22634
commit 841766b168
174 changed files with 26366 additions and 675 deletions

View File

@@ -0,0 +1,561 @@
# 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 fuel20 次调用约 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<String>, // <-- 关键:可指定前端渲染组件
pub options: Option<Vec<serde_json::Value>>, // <-- 关键:下拉选项列表
...
}
```
`ui_widget``options` 已经提供了 Select 的完整支持。前端 `PluginCRUDPage.tsx` 第 178-185 行已经处理了 `select` widget
```typescript
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 中声明方式:
```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<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 连接。
可行的改造方案:
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<Value>)
```
使用 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`
**Bugunique 字段没有创建唯一索引。**
此外,`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
- 筛选器:时间范围、客户类型等
- 实时更新(可选)
**关键依赖**:需要后端提供聚合查询 APICOUNT/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 + tabsCRM 核心需要)
- 第二期2-3 周dashboard + graph锦上添花
---
## 6. manifest.ui.pages 解析
### 当前结构
`manifest.rs` 第 94-109 行:
```rust
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>,
}
```
### 需要扩展为
```rust
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>,
}
```
### 影响范围
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<bool, String> {
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. 整体实施风险评估
### 高风险
#### R1db-query Host API 不可用(风险等级:高)
**问题**:当前 `db_query` 依赖预填充,但 `execute_wasm` 中没有预填充逻辑。CRM 插件在 WASM 内部无法执行关联查询。
**影响**WASM 内的 `handle_event` 无法查询关联数据。例如,收到 `workflow.task.completed` 事件后,无法查询关联的客户记录。
**缓解方案**
- 短期CRM 的关联查询全部走前端 REST APIWASM 内只做简单的 `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 永远返回 falseJWT 中间件也无法加载这些权限。
**修复量**:约 50 行代码改动install/uninstall 方法增加权限 CRUD
### 中风险
#### R4REST API 不支持过滤查询(风险等级:中)
**问题**`PluginDataListParams` 只有分页参数不支持字段过滤。CRM 需要按 `customer_id` 过滤联系人等场景。
**修复量**:约 80 行代码改动(后端 filter 解析 + SQL 构建 + 前端传参)。
#### R5前端 tabs 嵌套实现复杂度(风险等级:中)
**问题**tabs 页面类型需要支持动态组合不同子页面类型,且子页面需要从父页面获取过滤参数。这涉及组件设计模式的选择。
**缓解方案**:先实现基本的 tabs + crud 组合graph/timeline 作为子页面类型后续迭代。
#### R6前端页面类型路由分发风险等级
**问题**:当前 `App.tsx` 只有一个 `PluginCRUDPage` 路由。需要根据 manifest 中的 `page_type` 动态选择渲染组件。
**修复量**:约 100 行代码改动(新增路由分发组件 + 各页面类型组件)。
### 低风险
#### R7Fuel 限制(风险等级:低)
1000 万 fuel 对 CRM 插件绰绰有余。无需改动。
#### R8manifest 扩展(风险等级:低)
新增的 `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` 支持条件过滤
### 分期实施计划
#### 第一期:最小可用 CRM2-3 周)
- 5 个 JSONB 实体customer/contact/communication/customer_tag/customer_relationship
- plugin.toml manifest 定义
- WASM 插件 Rust crateinit + on_tenant_created + handle_event 基础逻辑)
- 前端 CRUD 页面(复用已有 PluginCRUDPage
- 前端 tabs 页面类型(客户详情页的基本信息+联系人+沟通记录)
- 前端 timeline 页面类型(沟通记录时间线)
- REST API filter 支持
#### 第二期增强功能2-3 周)
- 前端 tree 页面类型(客户层级树)
- 前端 dashboard 页面类型CRM 概览统计)
- 后端聚合查询 APICOUNT/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 运行时本身(它已被验证),而在于周边配套设施(权限、过滤、前端组件)的完善程度。