Files
erp/docs/superpowers/specs/2026-04-18-crm-plugin-platform-p0-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

19 KiB
Raw Blame History

CRM 插件平台标杆 — P0 基础能力设计规格

版本: v1.1 (修正版 — 基于代码审查发现,对齐现有实现) 日期: 2026-04-18 状态: Draft 定位: 插件平台标杆 — CRM 是试金石,打磨通用能力


1. 背景与动机

1.1 为什么要做这个

CRM 插件是 ERP 平台的第一个行业插件,当前状态是"客户通讯录 + 标签 + 关系图谱",距离一流 CRMSalesforce/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),包含 entityforeign_keyon_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 三种策略。需增强:

  1. 级联影响信息返回Restrict 时返回 affected_countrelation.name,方便前端展示
  2. 批量删除级联batch_delete (data_service.rs:417-520) 当前绕过级联,需补充
  3. many_to_many 级联:基于 through_entity 清理中间表记录

级联策略执行 (已有,需增强错误信息)

现有 data_service.rs:330-395 已实现。增强点:

  1. Restrict 错误增强:返回 affected_countrelation.name
  2. 批量删除级联batch_delete (data_service.rs:417-520) 当前绕过级联,需补充
  3. PATCH 校验partial_update (data_service.rs:291-327) 当前绕过 validate_data,需补充
  4. 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_datavalidate_ref_entities 调用,与 update 保持一致。

执行位置: data_service.rscreate_recordupdate_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.tsx
  • apps/web/src/pages/dashboard/DashboardWidgets.tsx
  • apps/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 页面应零改动正确渲染。

具体验证:

  1. Dashboard 显示 inventory 的 6 个实体统计,颜色按顺序分配
  2. Graph 如果 inventory 有关系数据,渲染正确(无数据则显示空状态)
  3. 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_datavalidate_ref_entities

前端 TypeScript

文件 改动类型 说明
apps/web/src/api/plugins.ts 修改 PluginEntitySchema 新增 relationsPluginFieldSchema 新增 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 功能验证

  1. 重新安装 CRM 插件,确认 5 个 relation 正确注册到 entity metadata
  2. 删除客户 → 确认关联数据正确级联
  3. 手机号/邮箱格式校验 → 确认前后端双重拦截
  4. Dashboard → 确认标题/颜色从 schema 动态生成
  5. 切换 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 规格