19 KiB
CRM 插件平台标杆 — P0 基础能力设计规格
版本: v1.1 (修正版 — 基于代码审查发现,对齐现有实现) 日期: 2026-04-18 状态: Draft 定位: 插件平台标杆 — CRM 是试金石,打磨通用能力
1. 背景与动机
1.1 为什么要做这个
CRM 插件是 ERP 平台的第一个行业插件,当前状态是"客户通讯录 + 标签 + 关系图谱",距离一流 CRM(Salesforce/HubSpot/Pipedrive)有显著差距。但更大的问题是:CRM 暴露的差距不在于 CRM 本身,而在于插件平台的基础能力缺失。
具体来说:
5 个实体之间有明确的 FK 关系,但 manifest 无法声明→ 已有PluginRelation+ 级联删除,但缺少name/display_field/关系类型等前端渲染信息- 35+ 字段有 required/unique/pattern 校验,但缺少
min_length/max_length/min_value/max_value扩展校验 - Dashboard/Graph 页面硬编码了 CRM 专属颜色和标题,第二个插件无法复用
- CRM 的
plugin.toml没有声明relations,导致现有级联能力未被使用 - 批量删除和 PATCH 部分更新绕过了现有校验
如果不在 P0 阶段补齐这些基础,所有后续业务功能(商机、合同、报价)都会建在不稳固的地基上。
1.2 设计原则
| 原则 | 含义 |
|---|---|
| 平台优先 | 每个能力都是平台层的,CRM 只是第一个使用者 |
| 零改动复用 | inventory/生产/财务插件不应为这些能力写任何额外代码 |
| Manifest 驱动 | 所有行为由 plugin.toml 声明驱动,不写硬编码 |
| 双层保障 | 前端即时反馈 + 后端最终防线,缺一不可 |
1.3 一流 CRM 差距分析摘要
| 类别 | 差距 | 本规格是否覆盖 |
|---|---|---|
| 实体关系 + 级联删除 | 致命 — 删除客户产生孤儿数据 | P0-1 覆盖 |
| 字段校验 + FK 完整性 | 严重 — 数据质量无保障 | P0-2 覆盖 |
| 前端通用化 | 中等 — 第二个插件无法复用 Dashboard/Graph | P0-3 覆盖 |
| 商机/漏斗/合同 | 严重 — 核心业务缺失 | P2(本规格不覆盖) |
| 导入导出/批量操作 | 中等 — ERP 刚需 | P1(后续规格) |
| 全局搜索/保存视图 | 中等 — UX 缺失 | P1(后续规格) |
| WASM 活化 | 低 — 当前空操作不影响功能 | P2(后续规格) |
2. P0-1: 实体关系声明 + ref_entity + 级联策略
2.1 Manifest Schema 扩展
现有基础:PluginRelation 已存在(manifest.rs:184-189),包含 entity、foreign_key、on_delete 三个字段。级联删除已在 data_service.rs:330-395 中实现。
扩展方向:在现有结构上新增字段,保持向后兼容。
# === 一对多关系 (customer → contacts) ===
[[schema.entities.relations]]
entity = "contact" # 目标实体 (已有字段)
foreign_key = "customer_id" # FK 字段 (已有字段)
on_delete = "cascade" # cascade | nullify | restrict (已有枚举)
# ↓ 新增字段 (可选,向后兼容)
name = "contacts" # 关系显示名,用于前端标签
type = "one_to_many" # 关系类型 (one_to_many | many_to_one | many_to_many)
display_field = "name" # EntitySelect 下拉显示字段
# === 多对一关系 (contact → customer,含自引用) ===
[[schema.entities.relations]]
entity = "customer"
foreign_key = "parent_id"
on_delete = "nullify"
name = "parent"
type = "many_to_one"
display_field = "name"
# === 多对多关系 (customer ↔ customer,通过中间表) ===
[[schema.entities.relations]]
entity = "customer"
foreign_key = "from_customer_id" # 中间表中的源 FK
on_delete = "nullify"
name = "related_customers"
type = "many_to_many"
through_entity = "customer_relationship"
through_source_field = "from_customer_id"
through_target_field = "to_customer_id"
关系类型定义 (新增 type 字段)
| 类型 | 含义 | foreign_key 位置 | CRM 场景 |
|---|---|---|---|
one_to_many |
一个父 → 多个子 | 子实体上 | customer → contacts |
many_to_one |
多个子 → 一个父 | 本实体上 | contact → customer |
many_to_many |
双向多对多 | 中间表上 | customer ↔ customer |
type字段为Option<RelationType>,默认OneToMany。不声明则现有行为不变。
级联策略 (保持现有枚举不变)
| 策略 | TOML 值 | 行为 | 适用场景 |
|---|---|---|---|
Cascade |
"cascade" |
子记录 deleted_at = now() |
强所有权:客户→联系人 |
Nullify |
"nullify" |
FK 字段设 NULL | 弱引用:联系人→上级客户 |
Restrict |
"restrict" |
有子记录时阻止删除(409) | 关键数据:不允许孤立 |
2.2 后端实现
数据结构扩展 (manifest.rs)
在现有 PluginRelation 上新增字段(不替换):
// 现有字段保持不变
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRelation {
pub entity: String, // 已有
pub foreign_key: String, // 已有
pub on_delete: OnDeleteStrategy, // 已有 (Cascade | Nullify | Restrict)
// ↓ 新增可选字段
#[serde(default)]
pub name: Option<String>,
#[serde(default, rename = "type")]
pub relation_type: Option<RelationType>,
#[serde(default)]
pub display_field: Option<String>,
// many_to_many 专属
#[serde(default)]
pub through_entity: Option<String>,
#[serde(default)]
pub through_source_field: Option<String>,
#[serde(default)]
pub through_target_field: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RelationType {
#[default]
OneToMany,
ManyToOne,
ManyToMany,
}
级联删除 (已有,需增强)
data_service.rs:330-395 已实现 Restrict/Nullify/Cascade 三种策略。需增强:
- 级联影响信息返回:Restrict 时返回
affected_count和relation.name,方便前端展示 - 批量删除级联:
batch_delete(data_service.rs:417-520) 当前绕过级联,需补充 - many_to_many 级联:基于
through_entity清理中间表记录
级联策略执行 (已有,需增强错误信息)
现有 data_service.rs:330-395 已实现。增强点:
- Restrict 错误增强:返回
affected_count和relation.name - 批量删除级联:
batch_delete(data_service.rs:417-520) 当前绕过级联,需补充 - PATCH 校验:
partial_update(data_service.rs:291-327) 当前绕过validate_data,需补充 - many_to_many 级联:基于
through_entity清理中间表记录
FK 存在性校验 (已有 validate_ref_entities)
data_service.rs:834-899 已实现 validate_ref_entities。需确保 partial_update (PATCH) 也调用此函数。
2.3 前端实现
前端类型扩展
apps/web/src/api/plugins.ts 需更新:
// PluginEntitySchema 新增
interface PluginEntitySchema {
// ... existing fields
relations?: PluginRelationSchema[];
}
interface PluginRelationSchema {
entity: string;
foreign_key: string;
on_delete: 'cascade' | 'nullify' | 'restrict';
name?: string;
type?: 'one_to_many' | 'many_to_one' | 'many_to_many';
display_field?: string;
}
// PluginFieldSchema 新增 validation 属性
interface PluginFieldSchema {
// ... existing fields
validation?: {
pattern?: string;
message?: string;
min_length?: number;
max_length?: number;
min_value?: number;
max_value?: number;
};
}
EntitySelect 增强 (已有基础)
字段有 ref_entity 属性时,CRUD 表单已自动渲染为 EntitySelect。增强点:
- 优先使用
relation.display_field作为下拉显示字段(fallback 到现有ref_label_field) - 关联子表标题使用
relation.name
详情页关联子表自动渲染
Entity 的 one_to_many relations 自动在详情页渲染为内嵌 CRUD 表格:
- Compact 模式 + 自动过滤
fk = parent_record.id - 支持新增/编辑/删除子记录
- 标题使用
relation.name
级联删除确认
删除有 incoming relations 的记录时,弹出确认:
确定删除客户「{name}」?
此操作将同时删除:
- 3 条联系人记录
- 5 条沟通记录
- 2 条标签记录
2.4 CRM plugin.toml 改造
为 customer 实体补充 relations:
[[schema.entities.relations]]
entity = "contact"
foreign_key = "customer_id"
on_delete = "cascade"
name = "contacts"
type = "one_to_many"
display_field = "name"
[[schema.entities.relations]]
entity = "communication"
foreign_key = "customer_id"
on_delete = "cascade"
name = "communications"
type = "one_to_many"
display_field = "subject"
[[schema.entities.relations]]
entity = "customer_tag"
foreign_key = "customer_id"
on_delete = "cascade"
name = "tags"
type = "one_to_many"
display_field = "tag_name"
[[schema.entities.relations]]
entity = "customer"
foreign_key = "parent_id"
on_delete = "nullify"
name = "parent"
type = "many_to_one"
display_field = "name"
为 contact 实体补充 relations:
[[schema.entities.relations]]
entity = "communication"
foreign_key = "contact_id"
on_delete = "cascade"
name = "communications"
type = "one_to_many"
display_field = "subject"
3. P0-2: 字段校验层
3.1 现有基础
已有实现:
validate_data(data_service.rs:797-831): required + pattern 正则校验validate_ref_entities(data_service.rs:834-899): FK 引用存在性校验FieldValidation(manifest.rs:53-57):pattern+message字段- unique 检查已在
create/update流程中实现
缺失部分:
min_length/max_length校验器min_value/max_value校验器- PATCH (partial_update) 绕过所有校验
- 前端 TypeScript 类型缺少
validation属性
3.2 Manifest Schema 扩展
在现有 [validation] 上新增字段(manifest.rs:53-57 已有 pattern + message):
[[schema.entities.fields]]
name = "phone"
field_type = "string"
display_name = "手机号"
[schema.entities.fields.validation]
pattern = "^1[3-9]\\d{9}$"
message = "请输入有效的手机号码"
min_length = 11
max_length = 11
[[schema.entities.fields]]
name = "credit_limit"
field_type = "decimal"
[schema.entities.fields.validation]
min_value = 0
max_value = 99999999
message = "信用额度必须在 0-99999999 之间"
校验类型定义
| 校验器 | manifest 字段 | 状态 | 说明 |
|---|---|---|---|
required |
field.required |
已有 | 值不能为 null/空字符串 |
unique |
field.unique |
已有 | 同 tenant 内值唯一 |
pattern |
validation.pattern + validation.message |
已有 | 正则匹配 |
ref_exists |
field.ref_entity |
已有 | FK 指向的记录存在且未删除 |
min_length / max_length |
validation.min_length / validation.max_length |
新增 | 字符串长度范围 |
min_value / max_value |
validation.min_value / validation.max_value |
新增 | 数值范围 |
3.3 后端实现
扩展 FieldValidation (manifest.rs:53-57)
在现有结构上新增 4 个可选字段:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldValidation {
pub pattern: Option<String>, // 已有
pub message: Option<String>, // 已有
// ↓ 新增
pub min_length: Option<usize>,
pub max_length: Option<usize>,
pub min_value: Option<f64>,
pub max_value: Option<f64>,
}
扩展 validate_data (data_service.rs:797-831)
在现有函数中追加 min_length/max_length/min_value/max_value 检查:
// 现有: required + pattern 检查 (已实现)
// 新增:
if let Some(validation) = &field.validation {
// min_length / max_length
if let Some(str_val) = val.as_str() {
if let Some(min) = validation.min_length {
if str_val.len() < min { return Err(...); }
}
if let Some(max) = validation.max_length {
if str_val.len() > max { return Err(...); }
}
}
// min_value / max_value (适用于 number/integer/decimal)
if let Some(num_val) = val.as_f64() {
if let Some(min) = validation.min_value {
if num_val < min { return Err(...); }
}
if let Some(max) = validation.max_value {
if num_val > max { return Err(...); }
}
}
}
修复 PATCH 校验缺失
partial_update (data_service.rs:291-327) 需要添加 validate_data 和 validate_ref_entities 调用,与 update 保持一致。
执行位置: data_service.rs 的 create_record 和 update_record 方法中,数据写入前调用 validate_record。
错误响应格式:
{
"success": false,
"error": "数据验证失败",
"details": [
{ "field": "phone", "message": "请输入有效的手机号码" },
{ "field": "customer_id", "message": "引用的客户不存在" }
]
}
3.4 前端实现
从 schema 自动生成 Ant Design Form rules(需先修复 TypeScript 类型缺失):
function generateFormRules(field: PluginFieldSchema): Rule[] {
const rules: Rule[] = [];
if (field.required) {
rules.push({ required: true, message: `${field.display_name}不能为空` });
}
if (field.validation?.pattern) {
rules.push({
pattern: new RegExp(field.validation.pattern),
message: field.validation.message || `${field.display_name}格式不正确`,
});
}
if (field.validation?.min_length || field.validation?.max_length) {
rules.push({
min: field.validation.min_length,
max: field.validation.max_length,
message: field.validation.message || `${field.display_name}长度不正确`,
});
}
return rules;
}
3.5 CRM plugin.toml 补充校验
# phone 字段
[schema.entities.fields.validation]
pattern = "^1[3-9]\\d{9}$"
message = "请输入有效的手机号码"
# email 字段
[schema.entities.fields.validation]
pattern = "^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"
message = "请输入有效的邮箱地址"
# credit_code 字段
[schema.entities.fields.validation]
pattern = "^[0-9A-HJ-NP-RTUW-Y]{2}\\d{6}[0-9A-HJ-NP-RTUW-Y]{10}$"
message = "请输入有效的统一社会信用代码"
# website 字段
[schema.entities.fields.validation]
pattern = "^https?://[\\w.-]+(?:\\.[\\w.-]+)+[/#?]?.*$"
message = "请输入有效的网址"
4. P0-3: 前端去硬编码
4.1 Dashboard 通用化
涉及文件:
apps/web/src/pages/dashboard/dashboardConstants.tsxapps/web/src/pages/dashboard/DashboardWidgets.tsxapps/web/src/pages/PluginDashboardPage.tsx
改造方案:
| 当前硬编码 | 通用化方案 |
|---|---|
ENTITY_COLORS: customer→indigo, contact→green, ... |
8 色调色板按 entity 顺序自动分配 |
ENTITY_ICONS: customer→TeamOutlined, ... |
从 page schema 的 icon 字段读取 |
| 标题 "CRM 数据全景视图" | {manifest.name} 统计概览 |
| 副标题 "实时掌握业务动态" | {manifest.description} 截取前 50 字 |
通用调色板:
const UNIVERSAL_PALETTE = [
'#6366f1', // indigo
'#22c55e', // green
'#f59e0b', // amber
'#8b5cf6', // violet
'#ef4444', // red
'#06b6d4', // cyan
'#f97316', // orange
'#ec4899', // pink
];
4.2 Graph 通用化
涉及文件: apps/web/src/pages/plugins/graph/graphConstants.ts
改造方案:
| 当前硬编码 | 通用化方案 |
|---|---|
RELATIONSHIP_COLORS: parent_child→indigo, ... |
调色板按 option 顺序循环 |
RELATIONSHIP_LABELS: parent_child→"母子", ... |
从 field.options[].label 读取 |
RELATIONSHIP_TYPES 固定 5 种 |
从 schema 动态生成 |
4.3 CRUD 表格列可配置
涉及文件: apps/web/src/pages/PluginCRUDPage.tsx
改造方案:
manifest page 新增可选字段 table_columns:
[[ui.pages]]
type = "crud"
entity = "customer"
table_columns = ["code", "name", "customer_type", "level", "status", "owner_id", "region", "industry"]
不声明时默认行为:
- 取前 8 个非 hidden 非 FK 字段
- 替换当前
fields.slice(0, 5)硬编码
4.4 验证标准
测试: 将 CRM 插件替换为 inventory 插件,Dashboard/Graph/CRUD 页面应零改动正确渲染。
具体验证:
- Dashboard 显示 inventory 的 6 个实体统计,颜色按顺序分配
- Graph 如果 inventory 有关系数据,渲染正确(无数据则显示空状态)
- CRUD 表格按
table_columns或默认 8 列显示
5. 关键文件清单
后端 Rust
| 文件 | 改动类型 | 说明 |
|---|---|---|
crates/erp-plugin/src/manifest.rs |
修改 | PluginRelation 新增 name/type/display_field/through_* 字段;FieldValidation 新增 min_length/max_length/min_value/max_value |
crates/erp-plugin/src/data_service.rs |
修改 | 扩展 validate_data 增加 min/max 校验;partial_update 补充校验调用;batch_delete 补充级联 |
crates/erp-plugin-crm/plugin.toml |
修改 | 补充 relations 声明 + validation 规则 |
注意:不新建
validation.rs,直接扩展现有validate_data和validate_ref_entities。
前端 TypeScript
| 文件 | 改动类型 | 说明 |
|---|---|---|
apps/web/src/api/plugins.ts |
修改 | PluginEntitySchema 新增 relations;PluginFieldSchema 新增 validation |
apps/web/src/pages/dashboard/dashboardConstants.tsx |
修改 | 去硬编码,通用调色板自动分配 |
apps/web/src/pages/dashboard/DashboardWidgets.tsx |
修改 | schema 驱动颜色/图标 |
apps/web/src/pages/PluginDashboardPage.tsx |
修改 | 通用标题/副标题 |
apps/web/src/pages/plugins/graph/graphConstants.ts |
修改 | 关系类型从 options 动态读取 |
apps/web/src/pages/PluginCRUDPage.tsx |
修改 | 可配置列数 + Form rules 自动生成 |
6. 验证方案
6.1 编译与测试
cargo check # 全 workspace 编译
cargo test --workspace # 全量测试
6.2 单元测试
validation.rs: 每种校验器独立测试 (required/unique/pattern/ref_exists/length/value range)data_service.rs: 级联策略测试 (cascade_soft_delete/set_null/restrict)
6.3 集成测试 (Testcontainers)
- 删除客户 → 验证联系人/沟通记录/标签级联软删除
- 删除有 restrict 关系的记录 → 验证 409 响应
- 创建联系人 → customer_id 不存在时验证 400
- 创建客户 → phone 格式不正确时验证 400 + 错误详情
- 创建客户 → code 已存在时验证 409
6.4 功能验证
- 重新安装 CRM 插件,确认 5 个 relation 正确注册到 entity metadata
- 删除客户 → 确认关联数据正确级联
- 手机号/邮箱格式校验 → 确认前后端双重拦截
- Dashboard → 确认标题/颜色从 schema 动态生成
- 切换 inventory 插件 → Dashboard/Graph 零改动渲染
6.5 前端验证
cd apps/web && pnpm dev
手动测试所有 CRM 页面,确认无回归。
7. 不在本规格范围内
| 项 | 原因 | 计划 |
|---|---|---|
| 商机 (Opportunity) / 销售漏斗 | CRM 业务功能,P2 范畴 | 后续规格 |
| 数据导入导出 (Excel) | 平台能力但工作量大 | P1 规格 |
| 通知规则 + 消息中心联动 | 需要跨模块协作 | P1 规格 |
| WASM 校验/计算 Hook | 平台能力但依赖 WASM 运行时增强 | P2 规格 |
| 全局搜索 / 保存视图 | UX 增强 | P1 规格 |
| Lead 线索实体 | CRM 业务功能 | P2 规格 |