34 KiB
CRM 客户管理插件设计规格
版本: 1.1 日期: 2026-04-16 状态: Draft 作者: ERP Team 审查: 三组专家审查通过(架构师、UX 架构师、高级开发者)+ Spec 审查修复 v1.1
1. 背景与目标
1.1 背景
ERP 平台底座已完成 Phase 1-6(身份权限、系统配置、工作流、消息中心)及 WASM 插件系统原型验证。现在需要构建第一个行业插件来验证插件系统在真实业务场景下的可行性。
1.2 目标
- 构建可用的 CRM 插件 — 客户信息、联系人、沟通记录、分类筛选、关系图谱
- 验证 WASM 插件系统 — 真实业务数据通过 Host API 读写 JSONB 动态表
- 沉淀通用 UI 能力 — 新增的页面类型和组件对所有未来插件可用
- 形成插件开发 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 | 否 | 手机号 |
| String | 否 | 邮箱 | |
| 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 中使用 type,Rust 结构体中映射为 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 时也未检查唯一性冲突。
修复:
- DDL 层面将
unique = true的索引改为CREATE UNIQUE INDEX,由 PostgreSQL 保证数据完整性 - INSERT 时捕获 PostgreSQL 唯一约束违反错误(error code
23505),转换为对用户友好的冲突错误 - 禁止使用应用层的"先 SELECT 再 INSERT"模式,避免并发竞态条件
5.1.2 插件权限未注册 + 数据 Handler 动态权限检查
文件:crates/erp-plugin/src/service.rs + handler/data_handler.rs
问题 A:install() 方法未将 manifest 声明的 permissions 写入 permissions 表。check-permission Host API 永远返回 false。
问题 B:data_handler.rs 中权限检查硬编码为 require_permission(&ctx, "plugin.list") / "plugin.admin",不支持按插件/实体/操作区分权限。
修复:
install()中遍历manifest.permissions,INSERT 到permissions表(带tenant_id)uninstall()中清理插件注册的权限- 权限码格式:
{plugin_id}.{code},如erp-crm.customer.list - 数据 Handler 改造:
data_handler.rs的权限检查从硬编码改为动态计算:- 从请求路径提取
plugin_id和entity_name - 从 AppState 查找插件的 manifest,确定实体对应的权限码
- 映射规则:list →
{prefix}.list,create/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 字段过滤、搜索、排序。
修复:
DynamicTableManager新增build_filtered_query_sql方法- SQL 使用参数化查询:
WHERE tenant_id = $1 AND deleted_at IS NULL AND data->>'customer_id' = $2 - 安全要求:
- filter JSON 的 key 必须通过
sanitize_identifier校验(只允许 ASCII 字母、数字、下划线),防止 SQL 注入 - filter JSON 的 value 必须通过参数化查询(
$N占位符)传入,不可拼接到 SQL 中 - search 关键词需转义 SQL LIKE 通配符(
%→\%,_→\_)
- filter JSON 的 key 必须通过
PluginDataListParams增加filter、search、sort_by、sort_order参数- 前端 API 调用传递
?filter={"customer_id":"xxx"}&search=关键词 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 G6(dynamic import 按需加载)
- 模式:以选中客户为中心,展示 1 跳关系,点击扩展
- 配置:
source_entity,source_field,target_field,edge_label_field,node_label_field - 交互:节点点击查看详情、拖拽、缩放、关系类型筛选
5.3.7 dashboard 页面类型(Phase 3)
- 组件:
@ant-design/charts+ Ant DesignStatistic/Card - 需配合
db-count/db-aggregateHost 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_iduninstall()时清理- 数据 CRUD 的 REST API 通过
check-permission检查对应权限 detail页面的关联实体同样受权限控制
8. 实施分期
Phase 1:基座增强(前置条件)
目标:修复 Bug + 增强查询能力 + 扩展 manifest + 新增 detail 页面类型
交付物:
- 修复唯一索引 Bug(
dynamic_table.rs) - 修复权限注册 Bug(
service.rs)+ 数据 Handler 动态权限检查(data_handler.rs) - REST API 支持过滤/搜索/排序(
dynamic_table.rs+data_service.rs+data_handler.rs) - Manifest schema 扩展:
PluginField增加 searchable/filterable/sortable/visible_when;PluginPage增加 page_type/tabs/sections 等 - 前端 CRUD 增强:筛选栏 + 搜索框 + 排序 + API 参数扩展
- 前端 detail 页面类型:Drawer + Tabs + 嵌套 CRUD
- 前端 visible_when 条件表单字段
- 数据校验层(required 检查 + 类型检查)
验收标准:
- 唯一字段插入重复值返回冲突错误
- 插件安装后权限在数据库中可见
- REST API 支持
?filter={"field":"value"}&search=keyword&sort_by=name - PluginCRUDPage 自动渲染筛选栏和搜索框
- 点击行打开 detail Drawer,关联实体自动过滤
- 企业/个人客户的差异化字段根据类型动态显示/隐藏
Phase 2:CRM 插件核心
目标:实现完整的 CRM WASM 插件 + tree/timeline/tabs 页面类型
交付物(依赖 Phase 1 全部完成):
- CRM WASM 插件 Rust 代码(init/on_tenant_created/handle_event)
- CRM plugin.toml manifest(5 个实体 + 页面声明 + 权限)
- 前端 tree 页面类型(Ant Design Tree)
- 前端 timeline 视图模式(Ant Design Timeline + Segmented 切换)
- 前端 tabs 容器页面类型
- 侧边栏动态菜单集成(manifest pages → 路由 → 菜单项)
验收标准:
- 上传 CRM 插件 WASM → 安装 → 启用,侧边栏出现 CRM 菜单
- 客户 CRUD 完整可用(创建/列表/详情/编辑/删除)
- 联系人按客户自动过滤,沟通记录按客户自动过滤
- 客户层级树正确展示
- 沟通记录可切换表格/时间线视图
- 个人/企业客户差异化字段正常工作
Phase 3:高级功能
目标:关系图谱 + 统计概览 + Host API 扩展
交付物(依赖 Phase 2 全部完成):
- 前端 graph 页面类型(AntV G6)
- 前端 dashboard 页面类型(@ant-design/charts)
- Host API 新增 db-count / db-aggregate
- 关系图谱按中心节点展开模式
- 客户统计概览(数量/分类分布/等级分布)
- 插件版本升级策略规划
9. 插件开发 Skill 提炼计划
CRM 插件开发完成后,将开发经验提炼为可复用的 skill,供后续插件开发使用。
9.1 Skill 内容
- 插件开发流程指南 — 从需求分析到上线的完整步骤
- Manifest 模板 — 各页面类型的配置模板和示例
- Rust 插件脚手架 — init/on_tenant_created/handle_event 的标准实现
- 可用页面类型清单 — 每种类型的能力、配置项、限制
- 测试检查清单 — 插件发布前的必检项
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 驱动:
required校验:data_service.rs的 create/update 方法检查必填字段是否存在- 字段类型校验:确保值类型与
field_type匹配(如Integer字段不接受字符串) - 扩展校验(后续版本):
PluginField可选增加validation字段,支持pattern(正则)、min_length、max_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 TABLE,Phase 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 + 关联实体) → 停用 → 卸载。