1027 lines
34 KiB
Markdown
1027 lines
34 KiB
Markdown
# 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
|
||
|
||
```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 反序列化:
|
||
|
||
```rust
|
||
#[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 结构:**
|
||
|
||
```rust
|
||
// 已被上方 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`
|
||
|
||
**问题 A**:`install()` 方法未将 manifest 声明的 permissions 写入 `permissions` 表。`check-permission` Host API 永远返回 false。
|
||
|
||
**问题 B**:`data_handler.rs` 中权限检查硬编码为 `require_permission(&ctx, "plugin.list")` / `"plugin.admin"`,不支持按插件/实体/操作区分权限。
|
||
|
||
**修复**:
|
||
1. `install()` 中遍历 `manifest.permissions`,INSERT 到 `permissions` 表(带 `tenant_id`)
|
||
2. `uninstall()` 中清理插件注册的权限
|
||
3. 权限码格式:`{plugin_id}.{code}`,如 `erp-crm.customer.list`
|
||
4. **数据 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 字段过滤、搜索、排序。
|
||
|
||
**修复**:
|
||
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` 增加 `filter`、`search`、`sort_by`、`sort_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 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 Design `Statistic` / `Card`
|
||
- 需配合 `db-count` / `db-aggregate` Host API
|
||
- 配置:声明统计卡片(字段、聚合方式、图表类型)
|
||
|
||
---
|
||
|
||
## 6. WASM 插件逻辑
|
||
|
||
### 6.1 init()
|
||
|
||
```rust
|
||
fn init() -> Result<(), String> {
|
||
// CRM 插件初始化:
|
||
// - 当前无需创建默认数据
|
||
// - init() 仅做基本状态检查
|
||
// - Fuel 消耗极低(< 10 万),远在 1000 万限制内
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
### 6.2 on_tenant_created()
|
||
|
||
```rust
|
||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||
// 为新租户创建 CRM 默认数据:
|
||
// - 无需创建默认客户,租户自行录入
|
||
// - 可选:创建默认标签分类
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
### 6.3 handle_event()
|
||
|
||
CRM V1 不订阅任何事件。manifest 中的 `events` 部分留空或省略,避免启动不必要的 tokio 事件监听 task。后续版本可按需添加。
|
||
|
||
```rust
|
||
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. 修复唯一索引 Bug(`dynamic_table.rs`)
|
||
2. 修复权限注册 Bug(`service.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_when;`PluginPage` 增加 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 2:CRM 插件核心
|
||
|
||
**目标**:实现完整的 CRM WASM 插件 + tree/timeline/tabs 页面类型
|
||
|
||
交付物(依赖 Phase 1 全部完成):
|
||
1. CRM WASM 插件 Rust 代码(init/on_tenant_created/handle_event)
|
||
2. CRM plugin.toml manifest(5 个实体 + 页面声明 + 权限)
|
||
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`):
|
||
|
||
```toml
|
||
[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`):
|
||
|
||
```typescript
|
||
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_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 + 关联实体) → 停用 → 卸载。
|