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

22 KiB
Raw Blame History

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 枚举:

pub enum PluginFieldType {
    String, Integer, Float, Boolean, Date, DateTime, Json, Uuid, Decimal,
}

没有 SelectEnum 类型。

解决方案:不需要扩展 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_widgetoptions 已经提供了 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" },
]

底层存储仍为 stringJSONB 的 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 完全依赖预填充。 filterpagination 参数被忽略(_filter, _pagination)。查询结果在 WASM 执行前由 Host 预填充到 HostState.query_results 中。

engine.rsexecute_wasm 方法(第 444-522 行)中并没有看到预填充查询结果的逻辑。HostState::new 只初始化了空的 query_results: HashMap::new()

CRM 关联查询场景

"联系人列表按 customer_id 过滤" 的需求有两种实现路径:

路径 A扩展 Host API 的 db-query推荐

需要修改 engine.rsexecute_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_datadata_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 方法:

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

Bugunique 字段没有创建唯一索引。

此外,engine.rsflush_opsPendingOp::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_iddynamic_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 建表部分):

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.rslist 只支持分页。

5.2 graph关系图

用途客户关系图customer_relationship 实体)

Ant Design 基础组件:没有内置 graph 组件

需要引入的库@ant-design/chartsG6 封装)或 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 结构支持 childrentabs 字段,并且在运行时动态组合不同类型的页面组件。

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 行:

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>,
}

影响范围

  1. manifest.rs:扩展 PluginPage 结构体 — 改动量小,只新增字段
  2. service.rsinstall 方法中需要将新的 manifest 数据存入 plugin_entity.schema_json — 无需改动(已序列化完整 entity 定义)
  3. 前端 plugin storeplugin.tsPluginMenuItem 需要增加 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.listplugin.admin 两个权限码。

m20260417_000034_seed_plugin_permissions.rs 也只种子了这两个权限。

CRM 需要 8 个权限码

CRM 设计的权限码如 crm.customer.listcrm.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.permissionsengine.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.rsinstall 方法需要遍历 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.rsinstall 方法没有将其写入 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.rsunique 字段只创建了普通索引不是唯一索引。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_typetabsfilters 字段都是 Option<>,不破坏现有解析。改动量约 30 行。


实施建议

必须先修复的 3 个问题1-2 天)

  1. 修复唯一索引dynamic_table.rsfield.unique 创建 CREATE UNIQUE INDEX
  2. 权限注册service.rsinstall 方法将 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 运行时本身(它已被验证),而在于周边配套设施(权限、过滤、前端组件)的完善程度。