# 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` | 标记为可搜索字段,前端渲染搜索框 | | `filterable` | `Option` | 标记为可筛选字段,前端渲染筛选下拉 | | `sortable` | `Option` | 标记为可排序字段 | | `visible_when` | `Option` | 条件显示表达式,如 `customer_type == 'enterprise'` | **`visible_when` 表达式语法定义**: 仅支持等值比较,格式为 `{field_name} == '{value}'`,用正则 `/^(\w+)\s*==\s*'([^']*)'$/` 解析。不支持不等、大于小于、逻辑运算。前端用 `Form.useWatch` 监听字段变化,匹配时显示、不匹配时隐藏。 **PluginPage 扩展:** | 字段 | 类型 | 说明 | |------|------|------| | `page_type` | `Option` | 页面类型: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, }, #[serde(rename = "crud")] Crud { label: String, entity: String, filter_field: Option, enable_views: Option>, }, } ``` 同样,`PluginPage` 使用 `#[serde(tag = "type")]` 区分不同页面类型,每种类型只包含自己需要的字段。验证规则:`crud` 类型 `entity` 必填,`tree` 类型 `id_field`/`parent_field`/`label_field` 必填,`detail` 类型 `sections` 必填,`tabs` 类型 `tabs` 必填。 | `tabs` | `Option>` | tabs 类型的子页面列表 | | `sections` | `Option>` | detail 类型的区段列表 | | `enable_search` | `Option` | 启用搜索 | | `enable_views` | `Option>` | 可用视图模式:table/timeline | | `id_field` | `Option` | tree 类型:ID 字段名 | | `parent_field` | `Option` | tree 类型:父级字段名 | | `label_field` | `Option` | tree/graph 类型:标签字段名 | | `filter_field` | `Option` | detail 内嵌 CRUD:过滤字段名 | **新增 PluginSection 结构:** ```rust // 已被上方 PluginSection enum 替代 ``` 所有新增字段均为 `Option`,不破坏现有 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) -> 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; search?: string; sort_by?: string; sort_order?: 'asc' | 'desc'; } ) { const params: Record = { 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 }>( `/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 + 关联实体) → 停用 → 卸载。