Files
erp/docs/superpowers/specs/2026-04-16-crm-plugin-design.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

34 KiB
Raw Blame History

CRM 客户管理插件设计规格

版本: 1.1 日期: 2026-04-16 状态: Draft 作者: ERP Team 审查: 三组专家审查通过架构师、UX 架构师、高级开发者)+ Spec 审查修复 v1.1


1. 背景与目标

1.1 背景

ERP 平台底座已完成 Phase 1-6身份权限、系统配置、工作流、消息中心及 WASM 插件系统原型验证。现在需要构建第一个行业插件来验证插件系统在真实业务场景下的可行性。

1.2 目标

  1. 构建可用的 CRM 插件 — 客户信息、联系人、沟通记录、分类筛选、关系图谱
  2. 验证 WASM 插件系统 — 真实业务数据通过 Host API 读写 JSONB 动态表
  3. 沉淀通用 UI 能力 — 新增的页面类型和组件对所有未来插件可用
  4. 形成插件开发 skill — 将 CRM 开发经验提炼为可复用的 skill加速后续插件开发

1.3 决策原则

  • 完全配置驱动 — 插件不写前端代码,所有 UI 通过 manifest 配置,基座渲染
  • 基座优先增强 — 新 UI 能力沉淀到基座组件库,所有插件共享
  • 先修基座再做插件 — 确保插件开发时有可靠的地基

2. 架构决策

2.1 插件形式WASM 插件(非内置 crate

理由

  • 作为第一个行业插件,需要验证 WASM 插件系统的端到端可行性
  • CRM 是典型的行业模块,适合作为插件的标杆实现
  • JSONB 动态表足以支撑 CRM 的数据量和查询复杂度

放弃内置 crate 的理由

  • 内置 crate 需要编译时链接,不能动态安装/卸载
  • 不符合"插件优先"的平台架构方向
  • CRM 数据模型相对简单,不需要 ORM 级别的类型安全

2.2 UI 策略:完全配置驱动 + 基座组件库

理由

  • 插件开发者只需写 Rust + 配置 manifest降低开发门槛
  • 所有插件共享同一套 Ant Design 组件UI 一致性有天然保证
  • 基座组件库像滚雪球,每个插件的需求都在丰富基座能力
  • 避免"每个插件一套前端代码"的维护灾难

新增的通用页面类型对后续插件的价值

页面类型 CRM 用途 进销存 生产 财务
CRUD 增强 客户/联系人/沟通记录 商品/订单/出入库 工单/BOM/工序 账单/发票/凭证
detail 客户 360° 详情 商品详情(含库存) 工单详情(含进度) 账单详情(含明细)
tree 客户层级 商品分类 BOM 结构 科目体系
timeline 沟通时间线 出入库记录 生产进度 交易流水
tabs 页面分组 页面分组 页面分组 页面分组
graph 客户关系图谱 供应链关系 工序关系
dashboard 客户统计 库存概览 产能概览 收支概览

3. 数据模型

3.1 实体总览

5 个 JSONB 动态表,表名格式 plugin_erp_crm_{entity_name}

实体 表名 用途
customer plugin_erp_crm_customer 客户信息(个人/企业)
contact plugin_erp_crm_contact 联系人
communication plugin_erp_crm_communication 沟通记录
customer_tag plugin_erp_crm_customer_tag 客户标签
customer_relationship plugin_erp_crm_customer_relationship 客户关系

所有实体共享标准字段(由动态表自动创建):id, tenant_id, data(JSONB), created_at, updated_at, created_by, updated_by, deleted_at, version

注意:表名由 DynamicTableManager::table_name(plugin_id, entity_name) 自动生成,格式为 plugin_{sanitized_plugin_id}_{sanitized_entity_name}其中非字母数字字符会被替换为下划线。CRM 插件 id 为 erp-crm,因此表名为 plugin_erp_crm_*

3.2 客户customer

字段 类型 必填 唯一 可搜索 可筛选 条件显示 说明
code String 客户编码
name String 客户名称
customer_type String(select) 类型enterprise/personal
industry String 行业
region String 地区
source String(select) 来源referral/ad/exhibition/other
level String(select) 等级potential/normal/vip/svip
status String(select) 状态active/inactive/blacklist
credit_code String customer_type == 'enterprise' 统一社会信用代码
id_number String customer_type == 'personal' 身份证号
parent_id Uuid 上级客户 ID层级关系
website String 网站
address String 地址
remark String(textarea) 备注

3.3 联系人contact

字段 类型 必填 说明
customer_id Uuid 所属客户
name String 姓名
position String 职务
department String 部门
phone String 手机号
email String 邮箱
wechat String 微信号
is_primary Boolean 是否主联系人
remark String 备注

3.4 沟通记录communication

字段 类型 必填 说明
customer_id Uuid 关联客户
contact_id Uuid 关联联系人
type String(select) 类型phone/email/meeting/visit/other
subject String 主题
content String(textarea) 内容
occurred_at DateTime 沟通时间
next_follow_up Date 下次跟进日期

3.5 客户标签customer_tag

字段 类型 必填 说明
customer_id Uuid 关联客户
tag_name String 标签名称
tag_category String 标签分类industry/region/source/custom

3.6 客户关系customer_relationship

字段 类型 必填 说明
from_customer_id Uuid 源客户
to_customer_id Uuid 目标客户
relationship_type String(select) 类型parent_child/sibling/partner/supplier/competitor
description String 关系描述

4. Manifest 配置

4.1 完整 plugin.toml

[metadata]
id = "erp-crm"
name = "客户管理"
version = "0.1.0"
description = "客户关系管理插件 — ERP 平台第一个行业插件"
author = "ERP Team"
min_platform_version = "0.1.0"

# ── 权限声明 ──

[[permissions]]
code = "crm.customer.list"
name = "查看客户"
description = "查看客户列表和详情"

[[permissions]]
code = "crm.customer.manage"
name = "管理客户"
description = "创建、编辑、删除客户"

[[permissions]]
code = "crm.contact.list"
name = "查看联系人"

[[permissions]]
code = "crm.contact.manage"
name = "管理联系人"

[[permissions]]
code = "crm.communication.list"
name = "查看沟通记录"

[[permissions]]
code = "crm.communication.manage"
name = "管理沟通记录"

[[permissions]]
code = "crm.tag.manage"
name = "管理客户标签"

[[permissions]]
code = "crm.relationship.list"
name = "查看客户关系"

[[permissions]]
code = "crm.relationship.manage"
name = "管理客户关系"

# ── 实体定义 ──

[[schema.entities]]
name = "customer"
display_name = "客户"

  [[schema.entities.fields]]
  name = "code"
  field_type = "String"
  required = true
  display_name = "客户编码"
  unique = true
  searchable = true

  [[schema.entities.fields]]
  name = "name"
  field_type = "String"
  required = true
  display_name = "客户名称"
  searchable = true

  [[schema.entities.fields]]
  name = "customer_type"
  field_type = "String"
  required = true
  display_name = "客户类型"
  ui_widget = "select"
  filterable = true
  options = [
    { label = "企业", value = "enterprise" },
    { label = "个人", value = "personal" }
  ]

  [[schema.entities.fields]]
  name = "industry"
  field_type = "String"
  display_name = "行业"
  filterable = true

  [[schema.entities.fields]]
  name = "region"
  field_type = "String"
  display_name = "地区"
  filterable = true

  [[schema.entities.fields]]
  name = "source"
  field_type = "String"
  display_name = "来源"
  ui_widget = "select"
  options = [
    { label = "推荐", value = "referral" },
    { label = "广告", value = "ad" },
    { label = "展会", value = "exhibition" },
    { label = "主动联系", value = "outreach" },
    { label = "其他", value = "other" }
  ]

  [[schema.entities.fields]]
  name = "level"
  field_type = "String"
  display_name = "等级"
  ui_widget = "select"
  filterable = true
  options = [
    { label = "潜在客户", value = "potential" },
    { label = "普通客户", value = "normal" },
    { label = "VIP", value = "vip" },
    { label = "SVIP", value = "svip" }
  ]

  [[schema.entities.fields]]
  name = "status"
  field_type = "String"
  required = true
  display_name = "状态"
  ui_widget = "select"
  filterable = true
  options = [
    { label = "活跃", value = "active" },
    { label = "停用", value = "inactive" },
    { label = "黑名单", value = "blacklist" }
  ]

  [[schema.entities.fields]]
  name = "credit_code"
  field_type = "String"
  display_name = "统一社会信用代码"
  visible_when = "customer_type == 'enterprise'"

  [[schema.entities.fields]]
  name = "id_number"
  field_type = "String"
  display_name = "身份证号"
  visible_when = "customer_type == 'personal'"

  [[schema.entities.fields]]
  name = "parent_id"
  field_type = "Uuid"
  display_name = "上级客户"

  [[schema.entities.fields]]
  name = "website"
  field_type = "String"
  display_name = "网站"

  [[schema.entities.fields]]
  name = "address"
  field_type = "String"
  display_name = "地址"

  [[schema.entities.fields]]
  name = "remark"
  field_type = "String"
  display_name = "备注"
  ui_widget = "textarea"

[[schema.entities]]
name = "contact"
display_name = "联系人"

  [[schema.entities.fields]]
  name = "customer_id"
  field_type = "Uuid"
  required = true
  display_name = "所属客户"

  [[schema.entities.fields]]
  name = "name"
  field_type = "String"
  required = true
  display_name = "姓名"
  searchable = true

  [[schema.entities.fields]]
  name = "position"
  field_type = "String"
  display_name = "职务"

  [[schema.entities.fields]]
  name = "department"
  field_type = "String"
  display_name = "部门"

  [[schema.entities.fields]]
  name = "phone"
  field_type = "String"
  display_name = "手机号"

  [[schema.entities.fields]]
  name = "email"
  field_type = "String"
  display_name = "邮箱"

  [[schema.entities.fields]]
  name = "wechat"
  field_type = "String"
  display_name = "微信号"

  [[schema.entities.fields]]
  name = "is_primary"
  field_type = "Boolean"
  display_name = "主联系人"

  [[schema.entities.fields]]
  name = "remark"
  field_type = "String"
  display_name = "备注"

[[schema.entities]]
name = "communication"
display_name = "沟通记录"

  [[schema.entities.fields]]
  name = "customer_id"
  field_type = "Uuid"
  required = true
  display_name = "关联客户"

  [[schema.entities.fields]]
  name = "contact_id"
  field_type = "Uuid"
  display_name = "关联联系人"

  [[schema.entities.fields]]
  name = "type"
  field_type = "String"
  required = true
  display_name = "类型"
  ui_widget = "select"
  filterable = true
  options = [
    { label = "电话", value = "phone" },
    { label = "邮件", value = "email" },
    { label = "会议", value = "meeting" },
    { label = "拜访", value = "visit" },
    { label = "其他", value = "other" }
  ]

  [[schema.entities.fields]]
  name = "subject"
  field_type = "String"
  required = true
  display_name = "主题"
  searchable = true

  [[schema.entities.fields]]
  name = "content"
  field_type = "String"
  required = true
  display_name = "内容"
  ui_widget = "textarea"

  [[schema.entities.fields]]
  name = "occurred_at"
  field_type = "DateTime"
  required = true
  display_name = "沟通时间"
  sortable = true

  [[schema.entities.fields]]
  name = "next_follow_up"
  field_type = "Date"
  display_name = "下次跟进日期"

[[schema.entities]]
name = "customer_tag"
display_name = "客户标签"

  [[schema.entities.fields]]
  name = "customer_id"
  field_type = "Uuid"
  required = true
  display_name = "关联客户"

  [[schema.entities.fields]]
  name = "tag_name"
  field_type = "String"
  required = true
  display_name = "标签名称"
  searchable = true

  [[schema.entities.fields]]
  name = "tag_category"
  field_type = "String"
  display_name = "标签分类"
  ui_widget = "select"
  options = [
    { label = "行业", value = "industry" },
    { label = "地区", value = "region" },
    { label = "来源", value = "source" },
    { label = "自定义", value = "custom" }
  ]

[[schema.entities]]
name = "customer_relationship"
display_name = "客户关系"

  [[schema.entities.fields]]
  name = "from_customer_id"
  field_type = "Uuid"
  required = true
  display_name = "源客户"

  [[schema.entities.fields]]
  name = "to_customer_id"
  field_type = "Uuid"
  required = true
  display_name = "目标客户"

  [[schema.entities.fields]]
  name = "relationship_type"
  field_type = "String"
  required = true
  display_name = "关系类型"
  ui_widget = "select"
  filterable = true
  options = [
    { label = "母子公司", value = "parent_child" },
    { label = "兄弟公司", value = "sibling" },
    { label = "合作伙伴", value = "partner" },
    { label = "供应商", value = "supplier" },
    { label = "竞争对手", value = "competitor" }
  ]

  [[schema.entities.fields]]
  name = "description"
  field_type = "String"
  display_name = "关系描述"

# ── 页面声明 ──

[[ui.pages]]
type = "tabs"
label = "客户管理"
icon = "team"

  [[ui.pages.tabs]]
  label = "客户列表"
  type = "crud"
  entity = "customer"
  enable_search = true
  enable_views = ["table"]

  [[ui.pages.tabs]]
  label = "客户层级"
  type = "tree"
  entity = "customer"
  id_field = "id"
  parent_field = "parent_id"
  label_field = "name"

[[ui.pages]]
type = "detail"
entity = "customer"
label = "客户详情"

  [[ui.pages.sections]]
  type = "fields"
  label = "基本信息"
  fields = ["code", "name", "customer_type", "industry", "region", "level", "status", "credit_code", "id_number", "website", "address", "remark"]

  [[ui.pages.sections]]
  type = "crud"
  label = "联系人"
  entity = "contact"
  filter_field = "customer_id"

  [[ui.pages.sections]]
  type = "crud"
  label = "沟通记录"
  entity = "communication"
  filter_field = "customer_id"
  enable_views = ["table", "timeline"]

[[ui.pages]]
type = "crud"
entity = "contact"
label = "联系人"
icon = "user"
enable_search = true

[[ui.pages]]
type = "crud"
entity = "communication"
label = "沟通记录"
icon = "message"
enable_search = true
enable_views = ["table", "timeline"]

[[ui.pages]]
type = "crud"
entity = "customer_tag"
label = "标签管理"
icon = "tags"

[[ui.pages]]
type = "crud"
entity = "customer_relationship"
label = "客户关系"
icon = "apartment"

# ── 事件订阅 ──
# CRM V1 不订阅任何事件,避免启动不必要的监听 task

4.2 Manifest Schema 扩展点

在现有 PluginManifest/PluginField/PluginPage 基础上新增的字段:

PluginField 扩展:

字段 类型 说明
searchable Option<bool> 标记为可搜索字段,前端渲染搜索框
filterable Option<bool> 标记为可筛选字段,前端渲染筛选下拉
sortable Option<bool> 标记为可排序字段
visible_when Option<String> 条件显示表达式,如 customer_type == 'enterprise'

visible_when 表达式语法定义

仅支持等值比较,格式为 {field_name} == '{value}',用正则 /^(\w+)\s*==\s*'([^']*)'$/ 解析。不支持不等、大于小于、逻辑运算。前端用 Form.useWatch 监听字段变化,匹配时显示、不匹配时隐藏。

PluginPage 扩展:

字段 类型 说明
page_type Option<String> 页面类型crud/tree/graph/timeline/tabs/detail。TOML 中使用 typeRust 结构体中映射为 page_type(避免关键字冲突),反序列化时用 #[serde(rename = "type")]

新增 PluginSection 结构(类型安全设计)

使用 Rust enum 表达不同类型的 section通过 #[serde(tag = "type")] 实现 TOML 反序列化:

#[serde(tag = "type")]
pub enum PluginSection {
    #[serde(rename = "fields")]
    Fields {
        label: String,
        fields: Vec<String>,
    },
    #[serde(rename = "crud")]
    Crud {
        label: String,
        entity: String,
        filter_field: Option<String>,
        enable_views: Option<Vec<String>>,
    },
}

同样,PluginPage 使用 #[serde(tag = "type")] 区分不同页面类型,每种类型只包含自己需要的字段。验证规则:crud 类型 entity 必填,tree 类型 id_field/parent_field/label_field 必填,detail 类型 sections 必填,tabs 类型 tabs 必填。 | tabs | Option<Vec<PluginPage>> | tabs 类型的子页面列表 | | sections | Option<Vec<PluginSection>> | detail 类型的区段列表 | | enable_search | Option<bool> | 启用搜索 | | enable_views | Option<Vec<String>> | 可用视图模式table/timeline | | id_field | Option<String> | tree 类型ID 字段名 | | parent_field | Option<String> | tree 类型:父级字段名 | | label_field | Option<String> | tree/graph 类型:标签字段名 | | filter_field | Option<String> | detail 内嵌 CRUD过滤字段名 |

新增 PluginSection 结构:

// 已被上方 PluginSection enum 替代

所有新增字段均为 Option<T>,不破坏现有 manifest 解析。


5. 基座增强

5.1 Bug 修复

5.1.1 唯一索引 Bug

文件crates/erp-plugin/src/dynamic_table.rs

问题unique = true 的字段创建的是 CREATE INDEX 而非 CREATE UNIQUE INDEX。INSERT 时也未检查唯一性冲突。

修复

  1. DDL 层面将 unique = true 的索引改为 CREATE UNIQUE INDEX,由 PostgreSQL 保证数据完整性
  2. INSERT 时捕获 PostgreSQL 唯一约束违反错误error code 23505),转换为对用户友好的冲突错误
  3. 禁止使用应用层的"先 SELECT 再 INSERT"模式,避免并发竞态条件

5.1.2 插件权限未注册 + 数据 Handler 动态权限检查

文件crates/erp-plugin/src/service.rs + handler/data_handler.rs

问题 Ainstall() 方法未将 manifest 声明的 permissions 写入 permissions 表。check-permission Host API 永远返回 false。

问题 Bdata_handler.rs 中权限检查硬编码为 require_permission(&ctx, "plugin.list") / "plugin.admin",不支持按插件/实体/操作区分权限。

修复

  1. install() 中遍历 manifest.permissionsINSERT 到 permissions 表(带 tenant_id
  2. uninstall() 中清理插件注册的权限
  3. 权限码格式:{plugin_id}.{code},如 erp-crm.customer.list
  4. 数据 Handler 改造data_handler.rs 的权限检查从硬编码改为动态计算:
    • 从请求路径提取 plugin_identity_name
    • 从 AppState 查找插件的 manifest确定实体对应的权限码
    • 映射规则list → {prefix}.listcreate/update/delete → {prefix}.manage
    • 调用 require_permission(&ctx, &computed_permission_code) 动态检查

5.1.3 过滤查询缺失

文件crates/erp-plugin/src/dynamic_table.rs + data_service.rs + data_handler.rs

问题REST 列表接口只支持分页,不支持按 JSONB 字段过滤、搜索、排序。

修复

  1. DynamicTableManager 新增 build_filtered_query_sql 方法
  2. SQL 使用参数化查询:WHERE tenant_id = $1 AND deleted_at IS NULL AND data->>'customer_id' = $2
  3. 安全要求
    • filter JSON 的 key 必须通过 sanitize_identifier 校验(只允许 ASCII 字母、数字、下划线),防止 SQL 注入
    • filter JSON 的 value 必须通过参数化查询($N 占位符)传入,不可拼接到 SQL 中
    • search 关键词需转义 SQL LIKE 通配符(%\%_\_
  4. PluginDataListParams 增加 filtersearchsort_bysort_order 参数
  5. 前端 API 调用传递 ?filter={"customer_id":"xxx"}&search=关键词
  6. searchable 字段自动创建 GIN 索引

5.2 Host API 增强

API 优先级 说明
db-query 过滤实现 P0 已有接口签名,只需实现 filter 参数的 SQL 构建
db-get-by-id P1 按 ID 查单条记录
db-count P1 计数查询,用于 dashboard 统计
db-exists P2 存在性检查
db-query-batch P2 批量外键查询,解决 N+1 问题
db-query-tree P3 PostgreSQL WITH RECURSIVE 递归树查询
db-aggregate P3 聚合查询COUNT/SUM/AVG

Phase 1-2 只需 P0 和 P1。P2-P3 留到 Phase 3 或后续插件需要时再实现。

5.3 前端通用页面类型

5.3.1 CRUD 增强Phase 1

扩展现有 PluginCRUDPage

  • 筛选栏:从 schema 中提取 filterable = true 的字段,渲染对应的 Select/Input 组件
  • 搜索框enable_search = true 时在表格上方显示搜索输入框
  • 排序sortable = true 的字段在表格列头显示排序图标
  • 视图切换enable_views 包含多个值时,右上角显示 Segmented 切换按钮

5.3.2 detail 页面类型Phase 1

  • 路由:不新增独立路由。当 manifest 中声明了某实体的 detail 页面时CRUD 列表页自动在操作列插入"详情"按钮
  • 渲染:点击"详情"按钮打开 Drawer不需要 URL 变化),传递选中记录的 ID
  • 结构Ant Design Drawer + Tabs
  • sections 类型
    • fields:用 Descriptions 组件展示字段值
    • crud:嵌套 CRUD 表格,通过 filter_field 自动过滤关联数据
  • 条件表单visible_when 条件字段用 Form.useWatch 监听变化,动态显示/隐藏

5.3.3 tree 页面类型Phase 2

  • 组件Ant Design Tree / DirectoryTree
  • 配置:id_field, parent_field, label_field
  • 数据加载REST API 加载全量数据,前端构建树结构
  • 交互:展开/折叠、点击节点在右侧显示详情面板

5.3.4 timeline 视图模式Phase 2

  • 不作为独立页面类型,而是 CRUD 页面的视图切换选项
  • 组件Ant Design Timeline
  • 配置:date_field, title_field, content_field, type_field
  • 与表格共享筛选和分页状态

5.3.5 tabs 容器Phase 2

  • 组件Ant Design Tabs
  • 每个 tab 递归匹配子页面类型
  • 侧边栏集成tabs 类型在侧边栏只显示一个菜单项(使用外层的 label 和 icon点击进入后展示多个 tab
  • 路由规则tabs 类型注册路由 /plugins/{pluginId}/{pageRoute},内部 tab 切换不改变 URL
  • 动态菜单生成:从 manifest 的 ui.pages 遍历生成菜单项,tabs 类型聚合为一个菜单项,其他类型各生成一个菜单项

5.3.6 graph 页面类型Phase 3

  • 组件AntV G6dynamic import 按需加载)
  • 模式:以选中客户为中心,展示 1 跳关系,点击扩展
  • 配置:source_entity, source_field, target_field, edge_label_field, node_label_field
  • 交互:节点点击查看详情、拖拽、缩放、关系类型筛选

5.3.7 dashboard 页面类型Phase 3

  • 组件:@ant-design/charts + Ant Design Statistic / Card
  • 需配合 db-count / db-aggregate Host API
  • 配置:声明统计卡片(字段、聚合方式、图表类型)

6. WASM 插件逻辑

6.1 init()

fn init() -> Result<(), String> {
    // CRM 插件初始化:
    // - 当前无需创建默认数据
    // - init() 仅做基本状态检查
    // - Fuel 消耗极低(< 10 万),远在 1000 万限制内
    Ok(())
}

6.2 on_tenant_created()

fn on_tenant_created(tenant_id: String) -> Result<(), String> {
    // 为新租户创建 CRM 默认数据:
    // - 无需创建默认客户,租户自行录入
    // - 可选:创建默认标签分类
    Ok(())
}

6.3 handle_event()

CRM V1 不订阅任何事件。manifest 中的 events 部分留空或省略,避免启动不必要的 tokio 事件监听 task。后续版本可按需添加。

fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
    // CRM V1: 无事件处理
    Ok(())
}

CRM 插件的 WASM 逻辑相对简单,主要是数据初始化和简单的事件响应。复杂查询通过 REST API 在前端直接完成,不经过 WASM。


7. 权限模型

7.1 权限码

9 个权限码,遵循 {plugin_id}.{entity}.{action} 格式:

权限码 名称 说明
crm.customer.list 查看客户 列表和详情
crm.customer.manage 管理客户 创建/编辑/删除
crm.contact.list 查看联系人
crm.contact.manage 管理联系人
crm.communication.list 查看沟通记录
crm.communication.manage 管理沟通记录
crm.tag.manage 管理客户标签
crm.relationship.list 查看客户关系
crm.relationship.manage 管理客户关系

7.2 权限注册

  • install() 时写入 permissions 表,关联 tenant_id
  • uninstall() 时清理
  • 数据 CRUD 的 REST API 通过 check-permission 检查对应权限
  • detail 页面的关联实体同样受权限控制

8. 实施分期

Phase 1基座增强前置条件

目标:修复 Bug + 增强查询能力 + 扩展 manifest + 新增 detail 页面类型

交付物:

  1. 修复唯一索引 Bugdynamic_table.rs
  2. 修复权限注册 Bugservice.rs+ 数据 Handler 动态权限检查(data_handler.rs
  3. REST API 支持过滤/搜索/排序(dynamic_table.rs + data_service.rs + data_handler.rs
  4. Manifest schema 扩展:PluginField 增加 searchable/filterable/sortable/visible_whenPluginPage 增加 page_type/tabs/sections 等
  5. 前端 CRUD 增强:筛选栏 + 搜索框 + 排序 + API 参数扩展
  6. 前端 detail 页面类型Drawer + Tabs + 嵌套 CRUD
  7. 前端 visible_when 条件表单字段
  8. 数据校验层required 检查 + 类型检查)

验收标准

  • 唯一字段插入重复值返回冲突错误
  • 插件安装后权限在数据库中可见
  • REST API 支持 ?filter={"field":"value"}&search=keyword&sort_by=name
  • PluginCRUDPage 自动渲染筛选栏和搜索框
  • 点击行打开 detail Drawer关联实体自动过滤
  • 企业/个人客户的差异化字段根据类型动态显示/隐藏

Phase 2CRM 插件核心

目标:实现完整的 CRM WASM 插件 + tree/timeline/tabs 页面类型

交付物(依赖 Phase 1 全部完成):

  1. CRM WASM 插件 Rust 代码init/on_tenant_created/handle_event
  2. CRM plugin.toml manifest5 个实体 + 页面声明 + 权限)
  3. 前端 tree 页面类型Ant Design Tree
  4. 前端 timeline 视图模式Ant Design Timeline + Segmented 切换)
  5. 前端 tabs 容器页面类型
  6. 侧边栏动态菜单集成manifest pages → 路由 → 菜单项)

验收标准

  • 上传 CRM 插件 WASM → 安装 → 启用,侧边栏出现 CRM 菜单
  • 客户 CRUD 完整可用(创建/列表/详情/编辑/删除)
  • 联系人按客户自动过滤,沟通记录按客户自动过滤
  • 客户层级树正确展示
  • 沟通记录可切换表格/时间线视图
  • 个人/企业客户差异化字段正常工作

Phase 3高级功能

目标:关系图谱 + 统计概览 + Host API 扩展

交付物(依赖 Phase 2 全部完成):

  1. 前端 graph 页面类型AntV G6
  2. 前端 dashboard 页面类型(@ant-design/charts
  3. Host API 新增 db-count / db-aggregate
  4. 关系图谱按中心节点展开模式
  5. 客户统计概览(数量/分类分布/等级分布)
  6. 插件版本升级策略规划

9. 插件开发 Skill 提炼计划

CRM 插件开发完成后,将开发经验提炼为可复用的 skill供后续插件开发使用。

9.1 Skill 内容

  1. 插件开发流程指南 — 从需求分析到上线的完整步骤
  2. Manifest 模板 — 各页面类型的配置模板和示例
  3. Rust 插件脚手架 — init/on_tenant_created/handle_event 的标准实现
  4. 可用页面类型清单 — 每种类型的能力、配置项、限制
  5. 测试检查清单 — 插件发布前的必检项

9.2 Skill 触发场景

  • 用户说"开发一个新插件"或"新建行业模块"时自动触发
  • 提供页面类型选择、manifest 生成、Rust 代码生成的向导式流程

10. 关键文件清单

基座修改文件

文件 改动类型 Phase
crates/erp-plugin/src/dynamic_table.rs Bug 修复 + 查询增强 1
crates/erp-plugin/src/service.rs Bug 修复(权限注册) 1
crates/erp-plugin/src/data_service.rs 过滤查询支持 1
crates/erp-plugin/src/handler/data_handler.rs REST API 参数扩展 1
crates/erp-plugin/src/manifest.rs Schema 扩展 1
crates/erp-plugin/src/host.rs Host API 过滤实现 1
crates/erp-plugin/src/dto.rs DTO 扩展filter/search 参数) 1
apps/web/src/pages/PluginCRUDPage.tsx CRUD 增强 1
apps/web/src/api/pluginData.ts API 参数扩展filter/search/sort 1
apps/web/src/pages/PluginDetailPage.tsx 新增 detail 页面 1
apps/web/src/pages/PluginTreePage.tsx 新增 tree 页面 2
apps/web/src/pages/PluginTimelineView.tsx 新增 timeline 视图 2
apps/web/src/pages/PluginTabsPage.tsx 新增 tabs 容器 2

CRM 插件新建文件

文件 说明 Phase
crates/erp-plugin-crm/Cargo.toml 插件 crate 配置 2
crates/erp-plugin-crm/src/lib.rs 插件入口WASM Guest 2
crates/erp-plugin-crm/plugin.toml 插件 manifest 2

CRM 插件 Cargo.toml 模板(参考 erp-plugin-test-sample

[package]
name = "erp-plugin-crm"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = "0.33"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

前端 API 扩展apps/web/src/api/pluginData.ts

export async function listPluginData(
  pluginId: string,
  entity: string,
  page = 1,
  pageSize = 20,
  options?: {
    filter?: Record<string, string>;
    search?: string;
    sort_by?: string;
    sort_order?: 'asc' | 'desc';
  }
) {
  const params: Record<string, string> = { page: String(page), page_size: String(pageSize) };
  if (options?.filter) params.filter = JSON.stringify(options.filter);
  if (options?.search) params.search = options.search;
  if (options?.sort_by) params.sort_by = options.sort_by;
  if (options?.sort_order) params.sort_order = options.sort_order;
  const { data } = await client.get<{ success: boolean; data: PaginatedResponse<PluginData> }>(
    `/plugins/${pluginId}/${entity}`, { params }
  );
  return data.data;
}

数据校验层

JSONB 动态表没有数据库层面的外键约束和字段级校验。运行时校验通过 manifest schema 驱动:

  1. required 校验data_service.rs 的 create/update 方法检查必填字段是否存在
  2. 字段类型校验:确保值类型与 field_type 匹配(如 Integer 字段不接受字符串)
  3. 扩展校验(后续版本):PluginField 可选增加 validation 字段,支持 pattern(正则)、min_lengthmax_length

已知限制JSONB 无法保证外键引用完整性(如 contact.customer_id 必须指向存在的客户。Phase 1 在 data_service.rs 中不做外键检查Phase 2 可选增加。


11. 风险与缓解

风险 概率 影响 缓解措施
JSONB 查询性能不足 GIN 索引 + searchable 字段自动创建索引
Host API 版本不兼容 CRM 开发前冻结 Host API v2 接口
G6 包体积过大 Dynamic import 按需加载
visible_when 表达式安全 禁用 eval用正则 /^(\w+)\s*==\s*'([^']*)'$/ 解析
插件权限与内置权限冲突 权限码加 plugin_id 前缀隔离
插件版本升级 JSONB 天然兼容新增字段;删除/重命名字段需在 manifest 中标记废弃;索引变更需 ALTER TABLEPhase 3 规划插件升级策略
JSONB 外键引用完整性 数据库层面无法保证Phase 1 不做外键检查Phase 2 可选在 data_service 中增加引用验证

12. 测试策略

CRM 插件作为第一个行业插件,其测试策略对后续插件有标杆作用。

12.1 Rust 侧测试

测试类型 覆盖内容 Phase
单元测试 manifest 解析新增字段、tabs 嵌套、PluginSection enum 1
单元测试 visible_when 表达式解析 1
单元测试 sanitize_identifier 对 filter key 的安全校验 1
单元测试 build_filtered_query_sql 参数化 SQL 构建 1
单元测试 唯一约束违反错误码 23505 的捕获和转换 1
集成测试 插件安装时权限注册到数据库 1
集成测试 过滤查询filter/search/sort端到端 1
集成测试 CRM 插件 WASM 加载/初始化/数据 CRUD 2

12.2 前端测试

测试类型 覆盖内容 Phase
组件测试 PluginCRUDPage 筛选栏/搜索框自动渲染 1
组件测试 visible_when 条件字段动态显示/隐藏 1
组件测试 PluginDetailPage Drawer + 嵌套 CRUD 1
组件测试 PluginTreePage 树形展示 2
组件测试 timeline 视图模式切换 2

12.3 端到端测试

完整流程:上传 CRM WASM → 安装(创建动态表 + 注册权限) → 启用WASM 初始化 + 事件监听) → 数据 CRUD客户/联系人/沟通记录) → 客户详情Drawer + 关联实体) → 停用 → 卸载。