Files
erp/plans/flickering-tinkering-pebble-agent-a785dda8d2f4eeebc.md
iven 841766b168
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
fix(用户管理): 修复用户列表页面加载失败问题
修复用户列表页面加载失败导致测试超时的问题,确保页面元素正确渲染
2026-04-19 08:46:28 +08:00

562 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 运行时本身(它已被验证),而在于周边配套设施(权限、过滤、前端组件)的完善程度。