fix(用户管理): 修复用户列表页面加载失败问题
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

修复用户列表页面加载失败导致测试超时的问题,确保页面元素正确渲染
This commit is contained in:
iven
2026-04-19 08:46:28 +08:00
parent 0ee9d22634
commit 841766b168
174 changed files with 26366 additions and 675 deletions

View File

@@ -0,0 +1,61 @@
# 插件侧边栏三级菜单重构
## Context
当前插件菜单是扁平结构,所有插件的页面(客户管理、联系人、沟通记录等)都平铺在"插件"分组下。随着更多插件接入,菜单会变得杂乱无章。需要改为三级结构:
```
插件(分组标题)
└ CRM插件名称可折叠
├ 客户管理
├ 联系人
├ 沟通记录
├ 标签管理
└ 客户关系
└ 未来插件名称(可折叠)
├ 页面1
└ 页面2
```
## 改动范围
仅涉及前端两个文件,无需后端改动(`PluginMenuItem.pluginId` 字段已存在)。
### 1. `apps/web/src/stores/plugin.ts`
**改动:** 新增 `PluginMenuGroup` 类型和 `pluginMenuGroups` getter。
```ts
export interface PluginMenuGroup {
pluginId: string;
pluginName: string; // 从 cachedPlugins 取 metadata.name
items: PluginMenuItem[];
}
```
在 store 中添加一个 `pluginMenuGroups` 计算属性Zustand getter`pluginId` 分组,取插件名称从 `plugins` 数组中查找。
### 2. `apps/web/src/layouts/MainLayout.tsx`
**改动:**
- 新增 `SidebarSubMenu` 组件:显示插件名称作为可折叠子标题,渲染其下级 `SidebarMenuItem`
- 子标题使用 `expand-icon` (右箭头) 指示可折叠,点击切换展开/收起
- 子标题样式:略深背景 + 左侧有缩进指示线
- 子菜单项有额外左缩进12px表示层级
- 折叠侧边栏时,子菜单标题只显示插件图标,悬浮弹出子项
**渲染逻辑:**
```
"插件" 标签
for each group in pluginMenuGroups:
<SidebarSubMenu title=group.pluginName items=group.items />
```
## 验证
1. 启动前端 `pnpm dev`,访问侧边栏
2. 确认 CRM 插件下所有 5 个菜单项嵌套在 "CRM" 子标题下
3. 点击 "CRM" 子标题可展开/收起
4. 折叠侧边栏后CRM 子项通过 tooltip 或悬浮展示
5. 点击各子项,路由正确跳转
6. 页面标题(面包屑)正确显示

View File

@@ -0,0 +1,667 @@
# CRM WASM 插件架构审查报告
> 审查人:后端架构师
> 日期2026-04-15
> 审查对象CRM 客户管理 WASM 插件设计方案
---
## 0. 审查前提:当前系统实际状态
经过对代码库的详细审查,我确认以下事实作为审查基础:
- **WIT 接口** (`crates/erp-plugin/wit/plugin.wit`)9 个 Host API 函数db-insert/db-query/db-update/db-delete/event-publish/config-get/log-write/current-user/check-permission
- **动态表结构** (`crates/erp-plugin/src/dynamic_table.rs`):每张表只有 `id`, `tenant_id`, `data`(JSONB), `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version` 这 9 列。所有业务字段存储在 `data` JSONB 列中。
- **索引策略**:只为 `required``unique` 字段创建 GIN 索引(`data->>'field_name'`),没有复合索引能力。
- **查询能力** (`data_service.rs`)`list()` 方法只支持按 tenant_id 过滤 + 分页,**不支持任何业务字段过滤**。`PluginDataListParams` 只有 `page`, `page_size`, `search` 三个参数。
- **前端 CRUD** (`PluginCRUDPage.tsx`):纯粹的表单驱动,无过滤、无关联实体展示、无自定义操作。
- **设计规格文档** (`docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md`) 附录 D 中规划了 `db-aggregate` 接口,但**实际 WIT 文件中并未实现**。
- **设计规格文档**规划了 `render-page` / `handle-action` 插件导出函数,但**实际 WIT 中也没有**。
**结论:当前系统是一个 MVP 级别的插件原型,距离支撑 CRM 这种多实体、多关系的业务插件还有显著差距。**
---
## 1. JSONB 动态表能否支撑 CRM 数据模型?
### 1.1 结论:基本可行,但需要大幅增强查询能力
JSONB 存储本身不是问题。PostgreSQL 的 JSONB 在索引支持下,单字段等值查询和范围查询性能完全可以接受(毫秒级)。关键问题在于当前系统的**查询能力几乎为零**。
### 1.2 关系查询的实现路径
对于 CRM 中"A 的所有子客户"这类关系查询:
**方案 A利用 JSONB 索引的等值过滤(推荐)**
`parent_id` 存储在 `data` JSONB 中。当前系统已有为 `required` 字段创建索引的逻辑:
```sql
-- 动态表已有的索引模式
CREATE INDEX idx_xxx_parent_id ON plugin_crm_customer (data->>'parent_id') WHERE deleted_at IS NULL;
```
查询"A 的直接子节点"只需:
```sql
SELECT * FROM plugin_crm_customer
WHERE tenant_id = $1 AND data->>'parent_id' = $2 AND deleted_at IS NULL;
```
这在有索引的情况下性能完全没问题10 万条记录也能在 5ms 内返回。
**方案 B递归查询查询所有子孙节点需要 db-query 增强**
当前的 `db_query` Host API 是预填充模式——Host 在执行 WASM 前就把查询结果塞进 `query_results` HashMap。WASM 内部只是读缓存。这意味着**插件无法控制 SQL 的构建过程**。
递归查询需要 PostgreSQL 的 `WITH RECURSIVE` CTE
```sql
WITH RECURSIVE descendants AS (
SELECT id, data, 0 as depth FROM plugin_crm_customer
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
UNION ALL
SELECT c.id, c.data, d.depth + 1 FROM plugin_crm_customer c
JOIN descendants d ON c.data->>'parent_id' = d.id::text
WHERE c.tenant_id = $2 AND c.deleted_at IS NULL AND d.depth < 10
)
SELECT * FROM descendants ORDER BY depth;
```
**当前系统无法执行这种查询。** 需要 Host API 层面支持。
### 1.3 具体建议
1. **为外键字段显式声明索引**:在 `PluginField` 中新增 `indexed: bool` 属性,或者引入 `references` 属性自动创建索引。CRM 中 `contact.customer_id``communication.customer_id``customer_tag.customer_id``customer_relationship.from_customer_id``customer_relationship.to_customer_id` 这些外键字段必须有索引。
2. **对 unique 约束的处理需要修正**:当前 `customer.code` 声明为 unique但索引只过滤了 `deleted_at IS NULL`。如果软删除后重建同名 code唯一索引会冲突。需要在索引中加入排除已删除记录的逻辑或使用 partial unique index
```sql
CREATE UNIQUE INDEX idx_xxx_code_uniq ON plugin_crm_customer (data->>'code') WHERE deleted_at IS NULL;
```
当前代码已经使用了 `WHERE deleted_at IS NULL`,这一点做得正确。但要确认 `sanitize_identifier` 生成的索引名不会因为表名过长而截断。
---
## 2. Host API 限制:跨实体查询
### 2.1 问题诊断
这是 CRM 插件面临的最严重的架构缺陷。CRM 的核心场景全部涉及跨实体:
- **客户列表 + 联系人数量**:需要 JOIN 或子查询
- **客户详情页展示联系人列表**:需要按 `customer_id` 过滤 contact
- **沟通记录按客户筛选**:需要按 `customer_id` 过滤 communication
- **标签筛选客户**:需要按 `tag_name` 在 customer_tag 中查,再反查 customer
当前 `db_query` 的实现(`host.rs` 第 99-109 行)只是从预填充的 HashMap 中取缓存:
```rust
fn db_query(&mut self, entity: String, _filter: Vec<u8>, _pagination: Vec<u8>) -> Result<Vec<u8>, String> {
self.query_results.get(&entity).cloned()
.ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity))
}
```
`_filter` 和 `_pagination` 参数被完全忽略了。
### 2.2 解决方案:引入结构化查询 API
不是加 JOIN那会让 Host API 变成 SQL 构建器),而是引入**两级查询增强**
**第一级(必须实现):单实体过滤查询**
在 Host API 中新增 `db-query-filtered`,或在现有 `db_query` 中实际处理 filter 参数:
```wit
/// 结构化过滤查询
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
```
filter 的 JSON 结构:
```json
{
"conditions": [
{"field": "customer_id", "op": "eq", "value": "uuid-here"},
{"field": "status", "op": "in", "value": ["active", "vip"]},
{"field": "occurred_at", "op": "gte", "value": "2026-01-01"}
],
"order_by": [{"field": "created_at", "dir": "desc"}],
"search": "关键词"
}
```
Host 层将其安全转换为参数化 SQL
```sql
SELECT id, data, created_at, updated_at, version
FROM plugin_crm_contact
WHERE tenant_id = $1 AND deleted_at IS NULL
AND data->>'customer_id' = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
```
这样 CRM 插件就能实现"查某个客户的所有联系人"、"查某个客户的所有沟通记录"。
**第二级(必须实现):聚合查询**
设计规格中规划了 `db-aggregate` 但未实现。CRM 需要它来显示统计信息:
```wit
db-aggregate: func(entity: string, query: list<u8>) -> result<list<u8>, string>;
```
query 结构:
```json
{
"group_by": ["data->>'status'"],
"aggregates": [
{"alias": "count", "func": "count", "field": "id"},
{"alias": "total_value", "func": "sum", "field": "data->>'amount'"}
],
"filter": {"field": "data->>'level'", "op": "eq", "value": "vip"}
}
```
**第三级(推荐实现):跨实体关联查询**
不要实现通用 JOIN太复杂且安全风险高而是实现一种受限的"关联加载"模式:
```wit
/// 按外键值批量查询(一次加载多个关联记录)
db-query-batch: func(entity: string, field: string, ids: list<string>) -> result<list<u8>, string>;
```
这解决了 CRM 中最常见的 N+1 查询问题。例如加载 20 个客户后,一次性查出所有关联的联系人:
```json
// 请求:查 contact 表中 customer_id 在 [id1, id2, ...] 中的记录
{
"entity": "contact",
"field": "customer_id",
"ids": ["uuid-1", "uuid-2", "..."]
}
```
Host 生成:
```sql
SELECT id, data, created_at, updated_at, version
FROM plugin_crm_contact
WHERE tenant_id = $1 AND deleted_at IS NULL
AND data->>'customer_id' = ANY($2::text[])
```
### 2.3 关于"多次 API 调用"的问题
在 WASM 插件模型下,**每次 API 调用都是在同一个 Store 内完成的**,不涉及网络开销。多次 `db_query` 调用的成本是:
- WASM 到 Host 的函数调用(纳秒级)
- Host 从预填充缓存中读取(纳秒级)
所以即使需要多次调用,性能也不是问题。**真正的问题是当前预填充机制不支持按业务字段过滤**。只要实现了第一级的结构化过滤CRM 插件的跨实体查询就可以通过以下模式实现:
```
// 插件内伪代码
let contacts = db_query("contact", {"field": "customer_id", "op": "eq", "value": customer_id}, pagination);
let communications = db_query("communication", {"field": "customer_id", "op": "eq", "value": customer_id}, pagination);
```
前端也可以直接通过 REST API 调用:
```
GET /api/v1/plugins/crm/contact?filter={"field":"customer_id","op":"eq","value":"uuid"}&page=1&page_size=20
```
---
## 3. 关系图谱的可行性
### 3.1 结论:可行但有数据量上限
CRM 关系图谱的核心查询是给定一个客户找出它的所有直接关系parent_child/partner/supplier 等)。
**查询模式**
```sql
-- 查询 A 的所有关系
SELECT * FROM plugin_crm_customer_relationship
WHERE tenant_id = $1 AND deleted_at IS NULL
AND (data->>'from_customer_id' = $2 OR data->>'to_customer_id' = $2);
```
这个查询在索引支持下完全可行。需要为 `from_customer_id` 和 `to_customer_id` 都创建索引。
**前端加载策略**
"加载全量关系+客户来渲染图谱"这个方案在以下条件下可行:
- 单租户客户量 < 5000
- 关系记录 < 10000
超出这个量级,需要改为"按中心节点展开"的模式:
1. 先加载中心客户的直接关系1 跳)
2. 用户点击某个关联客户时加载该客户的直接关系2 跳)
3. 前端用力导向图逐步渲染
### 3.2 建议
1. **不要设计成全量加载**。manifest 页面类型 `graph` 应支持配置 `depth` 参数(默认 1 跳,最大 3 跳),以及 `center_entity_id` 参数。
2. **后端提供"关系网络"专用查询**。不是新增 Host API而是在 data_handler 层增加一个通用的关系查询端点:
```
GET /api/v1/plugins/{plugin_id}/{entity}/graph?center_id=uuid&depth=2&relationship_types=parent_child,partner
```
这可以由基座的前端通用组件调用,不需要插件 WASM 参与。
3. **前端 graph 组件选型**:推荐 Ant Design 的 `@ant-design/graph` 或 G6。图谱渲染是纯前端能力不需要 WASM 介入。
---
## 4. parent_id 层级(递归查询)
### 4.1 问题分析
"查询某客户的所有子孙节点"在当前架构下确实是个难题。三种场景需要递归:
1. **组织架构式展示**:树形组件显示客户层级
2. **汇总统计**:某集团下所有子公司的总交易额
3. **权限继承**:父客户的负责人可以看到子客户
### 4.2 解决方案
**方案 AHost 端提供递归查询 API推荐**
新增 Host API
```wit
/// 递归查询树形结构的所有子孙节点
db-query-tree: func(entity: string, parent-field: string, root-id: string, max-depth: s32) -> result<list<u8>, string>;
```
Host 层使用 `WITH RECURSIVE` 实现:
```sql
WITH RECURSIVE tree AS (
-- 锚点:根节点
SELECT id, data, 0 as depth
FROM plugin_crm_customer
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
UNION ALL
-- 递归:子节点
SELECT c.id, c.data, t.depth + 1
FROM plugin_crm_customer c
JOIN tree t ON c.data->>'parent_id' = t.id::text
WHERE c.tenant_id = $2 AND c.deleted_at IS NULL AND t.depth < $3
)
SELECT id, data, depth FROM tree ORDER BY depth;
```
**方案 B物化路径denormalization**
在 customer 的 data 中存储物化路径 `path: "root_id/parent_id/self_id"`,然后可以用前缀匹配:
```sql
SELECT * FROM plugin_crm_customer
WHERE data->>'path' LIKE 'root_id/%' AND tenant_id = $1 AND deleted_at IS NULL;
```
这需要 GIN 索引支持 `LIKE` 前缀查询(`pg_trgm` 扩展或 `text_pattern_ops`)。
**推荐方案 A**。物化路径有数据一致性问题(移动节点时需要更新所有子节点的 path而递归 CTE 是 PostgreSQL 原生支持的,一次查询搞定。`max_depth` 参数防止无限递归。
### 4.3 对 tree 页面类型的建议
manifest 中 `type: tree` 的页面需要额外的配置:
```toml
[[ui.pages]]
route = "/crm/customer-tree"
entity = "customer"
type = "tree"
tree_config = { parent_field = "parent_id", label_field = "name", icon_field = "customer_type", max_depth = 5 }
```
基座前端根据 `tree_config` 渲染 Ant Design Tree 组件,使用 `db-query-tree` API 加载数据。
---
## 5. 缺失的关键能力
### 5.1 必须新增的 Host API
按优先级排序:
| 优先级 | API | 原因 |
|--------|-----|------|
| P0 | **db-query-filtered**(结构化过滤) | 没有它 CRM 插件完全无法工作。当前 db_query 的 filter 参数被忽略。 |
| P0 | **db-aggregate**(聚合查询) | CRM 仪表盘需要"按状态统计客户数"、"按级别统计客户数"等。设计规格中已规划但未实现。 |
| P0 | **db-query-by-id**(按 ID 查单条) | 当前 `db_query` 不支持按 ID 查询。虽然 REST API 有 GET by ID但 WASM 内部没有。 |
| P1 | **db-query-batch**(批量外键查询) | 解决 N+1 问题,关联实体加载。 |
| P1 | **db-query-tree**(递归树查询) | 支持客户层级树、组织架构等树形结构。 |
| P1 | **db-batch-insert**(批量插入) | 导入客户数据、批量创建联系人。 |
| P2 | **db-exists**(存在性检查) | 检查 code 是否已存在,比 count 更高效。 |
| P2 | **db-count**(计数) | 单独的计数查询,不返回数据。 |
### 5.2 必须增强的基座能力
| 能力 | 现状 | 需要 |
|------|------|------|
| **REST API 过滤** | `PluginDataListParams` 只有 `page/page_size/search` | 需要支持 `filter` 查询参数,支持 JSONB 字段的等值/范围/IN 查询 |
| **REST API 排序** | 固定 `ORDER BY created_at DESC` | 需要支持 `order_by` 参数 |
| **REST API 关联加载** | 无 | 需要支持 `?include=contact,communication` 参数,一次返回客户+联系人+沟通记录 |
| **Schema 唯一约束校验** | 只创建了索引,没有插入前校验 | 需要在 `PluginDataService::create` 中检查 unique 字段 |
| **实体间引用完整性** | 无 | 删除客户时应提示有关联的联系人/沟通记录。至少提供 `GET /plugins/{id}/{entity}/{record_id}/references` 端点 |
### 5.3 WASM 插件端需要的能力
设计规格中规划了 `render-page` 和 `handle-action`,但 WIT 中未实现。对于 CRM 插件,以下场景需要这两个函数:
- **客户 360 度视图**:在同一个页面展示客户基本信息 + 联系人列表 + 最近沟通记录 + 标签 + 关系图谱。这不是一个简单的 CRUD 页面,需要 WASM 返回复合 UI 指令。
- **自定义操作**"转为正式客户"、"合并客户"、"批量分配"等操作需要 WASM 处理业务逻辑。
建议在 WIT 中新增:
```wit
interface plugin-api {
init: func() -> result<_, string>;
on-tenant-created: func(tenant-id: string) -> result<_, string>;
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
// 新增
render-page: func(page-path: string, params: list<u8>) -> result<list<u8>, string>;
handle-action: func(page-path: string, action: string, data: list<u8>) -> result<list<u8>, string>;
}
```
---
## 6. Manifest ui.pages 设计审查
### 6.1 当前类型覆盖度评估
| 类型 | CRM 场景 | 是否覆盖 | 说明 |
|------|---------|---------|------|
| `crud` | 客户列表、联系人列表、沟通记录列表 | 覆盖 | 基本够用 |
| `tree` | 客户层级树 | 需增强 | 需要增加 `tree_config` 配置 |
| `graph` | 关系图谱 | 需增强 | 需要增加 `graph_config`relationship_entity, center_id, depth |
| `timeline` | 沟通记录时间线 | 新增 | Ant Design Timeline 组件天然支持 |
| `tabs` | 客户 360 度视图 | 需增强 | 需要支持嵌套子页面 |
| `dashboard` | CRM 首页统计 | 缺失 | 需要 `stat_cards` + `charts` 配置 |
| `kanban` | 客户跟进阶段看板 | 缺失 | 按客户等级/跟进状态分列 |
| `detail` | 客户详情页 | 缺失 | 不是 CRUD是复合视图 |
### 6.2 建议的页面类型扩展
```typescript
interface PluginPage {
route: string;
entity: string;
display_name: string;
icon?: string;
menu_group?: string;
// 页面类型扩展
type: "crud" | "tree" | "graph" | "timeline" | "tabs" | "dashboard" | "detail" | "custom";
// 类型专属配置
crud_config?: CrudConfig; // 列/过滤/排序/表单/操作
tree_config?: TreeConfig; // 父节点字段/标签字段/最大深度
graph_config?: GraphConfig; // 关系实体/关系类型/默认深度
dashboard_config?: DashboardConfig; // 统计卡片/图表
detail_config?: DetailConfig; // 关联实体/布局
tabs_config?: TabsConfig; // 子标签页列表
}
```
### 6.3 对 CRM 最关键的缺失detail 页面类型
CRM 最核心的交互不是列表 CRUD而是**客户 360 度详情页**
```
┌────────────────────────────────────────────────┐
│ 客户名称:某某集团 [编辑] [更多 ▾] │
│ 类型:企业 | 行业:制造业 | 级别VIP │
├────────────────────────────────────────────────┤
│ [基本信息] [联系人] [沟通记录] [关系图谱] [标签] │
├────────────────────────────────────────────────┤
│ 联系人列表(联系人的 CRUD自动过滤当前客户
│ + 新增联系人 │
│ ┌─────┬──────┬──────┬───────┬────┐ │
│ │姓名 │职位 │电话 │邮箱 │操作│ │
│ └─────┴──────┴──────┴───────┴────┘ │
└────────────────────────────────────────────────┘
```
这种页面需要:
1. 顶部展示主实体详情
2. 底部 tabs 展示关联实体列表
3. 关联实体自动按主实体 ID 过滤
建议 `detail` 类型配置:
```toml
[[ui.pages]]
route = "/crm/customer/:id"
entity = "customer"
type = "detail"
display_name = "客户详情"
[ui.pages.detail_config.header]
title_field = "name"
subtitles = [
{ field = "customer_type", label = "类型" },
{ field = "industry", label = "行业" },
{ field = "level", label = "级别", tag_colors = {vip = "gold", svip = "red"} }
]
actions = ["edit", "delete", { label = "转为正式客户", action = "activate", permission = "crm.customer.update" }]
[[ui.pages.detail_config.tabs]]
label = "联系人"
entity = "contact"
filter_field = "customer_id"
type = "crud"
[[ui.pages.detail_config.tabs]]
label = "沟通记录"
entity = "communication"
filter_field = "customer_id"
type = "timeline"
[[ui.pages.detail_config.tabs]]
label = "关系图谱"
entity = "customer_relationship"
type = "graph"
graph_config = { center_from = "from_customer_id", center_to = "to_customer_id" }
```
---
## 7. 权限模型审查
### 7.1 当前方案的 8 个权限码
根据 CRM 插件常见需求,推测 8 个权限码为:
- `crm.customer.list` / `crm.customer.create` / `crm.customer.update` / `crm.customer.delete`
- `crm.contact.list` / `crm.contact.create` / `crm.contact.update` / `crm.contact.delete`
### 7.2 不足之处
**问题 1缺少沟通记录和关系图谱的权限**
沟通记录communication和关系图谱customer_relationship是独立实体应该有独立的权限码。
**问题 2缺少数据范围权限data scope**
CRM 中最常见的权限需求不是"能不能看联系人",而是"能看到哪些客户的联系人"。典型的数据范围:
- **本人**:只看自己创建的客户
- **本部门**:看本部门所有人创建的客户
- **本部门及下级**:看本部门及子部门创建的客户
- **全部**:看所有客户
当前的 `check_permission` 只支持布尔型权限检查(有没有这个权限),不支持数据范围过滤。
**问题 3级联权限问题**
"联系人/沟通记录的访问是否应该依赖客户级别的权限?"——**是的,应该依赖**。如果用户看不到某个客户,就不应该看到该客户下的联系人和沟通记录。
### 7.3 建议的权限模型
**权限码16 个)**
```
crm.customer.list # 查看客户列表
crm.customer.create # 创建客户
crm.customer.update # 编辑客户
crm.customer.delete # 删除客户
crm.customer.export # 导出客户数据
crm.contact.list # 查看联系人
crm.contact.create # 创建联系人
crm.contact.update # 编辑联系人
crm.contact.delete # 删除联系人
crm.communication.list # 查看沟通记录
crm.communication.create # 创建沟通记录
crm.communication.update # 编辑沟通记录
crm.communication.delete # 删除沟通记录
crm.relationship.view # 查看关系图谱
crm.tag.manage # 管理标签分类
crm.customer.all # 数据范围:查看所有客户(默认只看自己的)
```
**数据范围过滤的实现**
这不是插件自身能解决的,需要基座支持。建议在 `current_user` API 返回的数据中包含数据范围信息,或者在 Host API 层增加数据范围过滤:
```wit
/// 扩展 current_user 返回值
current-user: func() -> result<list<u8>, string>;
// 返回结构增加 data_scope 字段
// { "id": "...", "tenant_id": "...", "data_scope": "all" | "department" | "department_tree" | "self" }
```
然后在 `db_query` 的过滤逻辑中自动注入 `created_by` 过滤。
**关于级联权限**:建议在 REST API 层处理。当查询联系人时,如果用户没有 `crm.customer.all`,则联系人查询自动 JOIN customer 表检查 `created_by`
```sql
SELECT c.* FROM plugin_crm_contact c
JOIN plugin_crm_customer cu ON c.data->>'customer_id' = cu.id::text
WHERE c.tenant_id = $1 AND c.deleted_at IS NULL
AND (cu.data->>'created_by' = $2 OR cu.data->>'level' IN ('vip', 'svip'))
-- VIP 客户所有人可见
```
这种过滤逻辑复杂且与业务强相关,建议在 WASM 插件的 `handle_action` 中实现,或者作为基座的通用数据范围机制。
---
## 8. 综合评估与实施建议
### 8.1 可行性评估
| 维度 | 评分 | 说明 |
|------|------|------|
| JSONB 数据存储 | 8/10 | PostgreSQL JSONB 足够支撑,索引机制需增强 |
| 单实体 CRUD | 6/10 | 基本可用,但查询/排序/过滤能力严重不足 |
| 跨实体关联 | 3/10 | 当前完全不支持,是最需要补强的部分 |
| 树形层级 | 3/10 | 需要递归查询支持 |
| 关系图谱 | 5/10 | 存储可行,查询/渲染需要增强 |
| 权限模型 | 5/10 | 权限码够用,数据范围权限缺失 |
| UI 配置驱动 | 4/10 | 缺少 detail/dashboard/kanban 类型 |
### 8.2 实施路径建议
**Phase 1基座查询能力增强前置条件约 3-5 天)**
1. 实现 `db_query` 的 filter 参数解析和 SQL 构建
2. 实现 `db-aggregate` Host API
3. REST API 支持 filter / order_by 参数
4. 为外键字段自动创建索引
5. 实现 unique 约束的插入前校验
**Phase 2CRM 核心功能(约 5-7 天)**
1. 创建 CRM 插件 crate5 个实体)
2. 实现客户 + 联系人 + 沟通记录的 CRUD
3. 实现 `type: detail` 前端通用组件
4. 实现客户详情页tabs 嵌套关联实体)
**Phase 3高级功能约 3-5 天)**
1. 实现 `db-query-tree` 支持客户层级
2. 实现关系图谱页面
3. 实现数据范围权限过滤
4. 实现 `render-page` / `handle-action` 支持自定义操作
### 8.3 风险提示
1. **最大的风险不是技术,是 Host API 的演进方向**。每增加一个 Host API 函数,就意味着所有已安装的插件都依赖这个接口。建议在 CRM 插件开发前先冻结 Host API v2包含上述增强然后所有插件基于 v2 开发。
2. **JSONB 动态表的性能天花板**。单表数据量超过 100 万条时JSONB 的查询性能会显著下降(相比原生列)。如果 CRM 模块被高频使用长期应考虑将高频实体customer升级为原生列存储。
3. **前端通用组件的复杂度**。detail + graph + tree + timeline 这四种页面类型,每种都需要基座提供通用实现。这不是 CRM 独有的需求,而是所有行业插件的共同需求。建议将这些组件沉淀为基座的 `PluginUI` 组件库,而不是 CRM 插件的一部分。
---
## 附录Host API v2 建议的 WIT 定义
```wit
package erp:plugin;
interface host-api {
// === 基础 CRUD ===
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
db-get-by-id: func(entity: string, id: string) -> result<list<u8>, string>;
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
db-update: func(entity: string, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
db-delete: func(entity: string, id: string) -> result<_, string>;
db-exists: func(entity: string, filter: list<u8>) -> result<bool, string>;
db-count: func(entity: string, filter: list<u8>) -> result<s64, string>;
// === 批量操作 ===
db-query-batch: func(entity: string, field: string, ids: list<string>) -> result<list<u8>, string>;
db-batch-insert: func(entity: string, items: list<list<u8>>) -> result<list<list<u8>>, string>;
// === 高级查询 ===
db-aggregate: func(entity: string, query: list<u8>) -> result<list<u8>, string>;
db-query-tree: func(entity: string, parent-field: string, root-id: string, max-depth: s32) -> result<list<u8>, string>;
// === 事件总线 ===
event-publish: func(event-type: string, payload: list<u8>) -> result<_, string>;
// === 配置 ===
config-get: func(key: string) -> result<list<u8>, string>;
// === 日志 ===
log-write: func(level: string, message: string);
// === 用户/权限 ===
current-user: func() -> result<list<u8>, string>;
check-permission: func(permission: string) -> result<bool, string>;
}
interface plugin-api {
init: func() -> result<_, string>;
on-tenant-created: func(tenant-id: string) -> result<_, string>;
on-tenant-deleted: func(tenant-id: string) -> result<_, string>;
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
render-page: func(page-path: string, params: list<u8>) -> result<list<u8>, string>;
handle-action: func(page-path: string, action: string, data: list<u8>) -> result<list<u8>, string>;
}
world plugin-world {
import host-api;
export plugin-api;
}
```
共计 15 个 Host API 函数vs 当前 9 个6 个 Plugin API 函数vs 当前 3 个)。

View File

@@ -0,0 +1,561 @@
# CRM WASM 插件实现可行性审查报告
## 审查范围
基于对以下核心代码的完整审查:
- `crates/erp-plugin/wit/plugin.wit` — WIT 接口定义
- `crates/erp-plugin/src/host.rs` — Host API 实现
- `crates/erp-plugin/src/engine.rs` — WASM 引擎Fuel 限制、执行流程)
- `crates/erp-plugin/src/manifest.rs` — 插件清单结构
- `crates/erp-plugin/src/dynamic_table.rs` — 动态表管理建表、CRUD SQL
- `crates/erp-plugin/src/data_service.rs` — 数据服务层
- `crates/erp-plugin/src/data_handler.rs` — API handler权限检查
- `crates/erp-plugin/src/service.rs` — 插件生命周期管理
- `crates/erp-plugin/src/module.rs` — 路由注册
- `apps/web/src/pages/PluginCRUDPage.tsx` — 前端通用 CRUD 页面
- `apps/web/src/stores/plugin.ts` — 插件菜单 store
- `crates/erp-server/migration/src/m20260417_000034_seed_plugin_permissions.rs` — 权限种子数据
---
## 1. WASM 插件逻辑复杂度
### init() — 需要做什么
CRM 插件的 `init()` 应当做以下事情:
- 日志记录初始化信息
- 可选:通过 `config_get` 读取 CRM 相关配置
- 可选:通过 `db_insert` 创建默认字典数据(如客户类型、级别、来源等默认选项)
**复杂度:低。** 主要是 `log_write` + 若干 `db_insert` 插入初始配置数据。涉及的逻辑只是 JSON 构造和 Host API 调用。1000 万 Fuel 绰绰有余。
### on_tenant_created() — 需要做什么
为新建租户创建 CRM 默认数据:
- 插入默认客户类型字典(潜在客户、意向客户、成交客户、流失客户)
- 插入默认行业分类
- 插入默认客户来源选项
- 可选创建默认客户级别A/B/C/D
**复杂度:低-中。** 纯数据初始化,大概需要 10-20 次 `db_insert` 调用。每次 `db_insert` 只是构造 JSON + 调用 Host API 入队 pending_ops不涉及复杂计算。Fuel 消耗估算:每次 Host API 调用约 5000-10000 fuel20 次调用约 20 万 fuel远低于 1000 万限制。
### handle_event() — 需要做什么
取决于 CRM 订阅了哪些事件。典型场景:
- 订阅 `user.created`:自动为客户经理创建关联记录
- 订阅 `workflow.task.completed`:处理客户审批流程完成事件
- 订阅自定义 CRM 事件:如 `crm.customer.created` 触发欢迎邮件模板创建
**复杂度:中。** 需要模式匹配 `event_type`,解析 JSON payload可能需要 `db_query` 查询关联数据,然后 `db_insert`/`db_update` 写入结果。关键限制是 `db_query` 只能访问预填充的数据。
### Fuel 限制评估
当前默认 Fuel 为 1000 万(`engine.rs` 第 32 行)。对于 CRM 插件的三种场景:
| 场景 | 预估 Fuel | 是否足够 |
|------|-----------|----------|
| init() | 5-20 万 | 充裕 |
| on_tenant_created() | 10-30 万 | 充裕 |
| handle_event() | 5-50 万 | 充裕 |
**结论Fuel 限制不是瓶颈。** 1000 万 fuel 对 CRM 的业务逻辑JSON 解析 + 若干 Host API 调用)绰绰有余。真正的复杂业务逻辑(报表聚合、批量操作)应放在 Host 端而不是 WASM 内部。
---
## 2. 动态表 Schema 声明 — Select 类型
### 当前支持
`manifest.rs` 第 66-76 行定义了 `PluginFieldType` 枚举:
```rust
pub enum PluginFieldType {
String, Integer, Float, Boolean, Date, DateTime, Json, Uuid, Decimal,
}
```
没有 `Select``Enum` 类型。
### 解决方案:不需要扩展 manifest schema
当前 `PluginField` 已经有两个关键字段(`manifest.rs` 第 50-61 行):
```rust
pub struct PluginField {
pub name: String,
pub field_type: PluginFieldType,
pub ui_widget: Option<String>, // <-- 关键:可指定前端渲染组件
pub options: Option<Vec<serde_json::Value>>, // <-- 关键:下拉选项列表
...
}
```
`ui_widget``options` 已经提供了 Select 的完整支持。前端 `PluginCRUDPage.tsx` 第 178-185 行已经处理了 `select` widget
```typescript
case 'select':
return (
<Select>
{(field.options || []).map((opt) => (
<Select.Option key={String(opt.value)} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
);
```
**结论CRM 的 select 字段不需要扩展 manifest。** 在 plugin.toml 中声明方式:
```toml
[[schema.entities.fields]]
name = "customer_type"
field_type = "string"
ui_widget = "select"
options = [
{ label = "潜在客户", value = "potential" },
{ label = "意向客户", value = "intention" },
{ label = "成交客户", value = "closed" },
{ label = "流失客户", value = "churned" },
]
```
底层存储仍为 `string`JSONB 的 `data->>'customer_type'`),数据库层不需要 enum 约束。
---
## 3. 关联实体查询
### 当前 db-query 的限制
查看 `host.rs` 第 99-109 行,`db_query` 的实现:
```rust
fn db_query(&mut self, entity: String, _filter: Vec<u8>, _pagination: Vec<u8>) -> Result<Vec<u8>, String> {
self.query_results
.get(&entity)
.cloned()
.ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity))
}
```
**关键发现:当前 db-query 完全依赖预填充。** `filter``pagination` 参数被忽略(`_filter`, `_pagination`)。查询结果在 WASM 执行前由 Host 预填充到 `HostState.query_results` 中。
`engine.rs``execute_wasm` 方法(第 444-522 行)中并没有看到预填充查询结果的逻辑。`HostState::new` 只初始化了空的 `query_results: HashMap::new()`
### CRM 关联查询场景
"联系人列表按 customer_id 过滤" 的需求有两种实现路径:
**路径 A扩展 Host API 的 db-query推荐**
需要修改 `engine.rs``execute_wasm` 方法,在 WASM 执行前解析 filter 参数,执行真实 SQL 查询,将结果预填充。或者更直接的方案——让 `db_query` 在调用时实时执行查询,而不是依赖预填充。
这意味着需要在 `HostState` 中持有 `DatabaseConnection` 的引用,或者将 `db_query` 改为延迟执行模式。但当前架构是 WASM 在 `spawn_blocking` 中同步执行,无法直接持有异步的 DB 连接。
可行的改造方案:
1. `db_query` 仍然走预填充模式,但在 `execute_wasm` 前根据 WASM 函数类型智能预填充(不现实,因为不知道插件会查什么)
2. 改为同步查询模式:在 `HostState` 中持有同步的 DB 连接(需要 `blocking_spawn` 内部再 spawn async task
3. **最佳方案**:前端直接调用 REST API 查询关联数据,不走 WASM 的 db-query
**路径 B前端直接按 customer_id 过滤(当前最可行)**
CRM 的关联查询(联系人与客户、沟通记录与客户)不需要走 WASM 的 `db-query`。前端 `PluginCRUDPage` 已经可以直接调用 `GET /api/v1/plugins/{plugin_id}/{entity}?page=1` 来获取联系人列表。
但当前 REST API 的 `list_plugin_data``data_handler.rs` 第 25-57 行)也不支持过滤参数。`PluginDataListParams` 只有 `page`, `page_size`, `search`
**需要做的改造:**
1. **后端**:在 `PluginDataListParams` 中增加 `filter` 参数JSON 格式,如 `{"customer_id": "xxx"}`
2. **后端**:在 `PluginDataService::list` 中解析 filter构建带 WHERE 条件的 SQL
3. **前端**`PluginCRUDPage` 接收 URL 参数如 `?filter[customer_id]=xxx`,传给 API
`dynamic_table.rs` 需要新增一个 `build_filtered_query_sql` 方法:
```rust
pub fn build_filtered_query_sql(
table_name: &str,
tenant_id: Uuid,
filters: &serde_json::Value, // {"customer_id": "xxx", "status": "active"}
limit: u64,
offset: u64,
) -> (String, Vec<Value>)
```
使用 PostgreSQL 的 JSONB 查询操作符:`data->>'customer_id' = $N`
**结论:关联查询需要扩展现有 API但改造量不大。** 主要是在 REST API 层增加 filter 支持,不需要改 WASM 运行时。这是中等工作量的改造。
---
## 4. 唯一性约束
### customer.code 唯一性
`dynamic_table.rs` 第 67-87 行,建表时会为 `unique: true` 的字段创建索引:
```rust
if field.unique || field.required {
let idx_sql = format!(
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" \
(\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
);
}
```
但这里只创建了普通索引(`CREATE INDEX`),不是唯一索引。`field.unique` 判断只影响了索引名称后缀(`"uniq"` vs `"idx"`),索引类型始终是 `CREATE INDEX`,不是 `CREATE UNIQUE INDEX`
**Bugunique 字段没有创建唯一索引。**
此外,`engine.rs``flush_ops``PendingOp::Insert`(第 543-560 行)执行的是普通 INSERT没有做唯一性检查
```rust
let (sql, values) = DynamicTableManager::build_insert_sql_with_id(
&table_name, id_uuid, tenant_id, user_id, &parsed_data
);
txn.execute(Statement::from_sql_and_values(...)).await?;
```
`build_insert_sql_with_id``dynamic_table.rs` 第 141-162 行)也是普通 INSERT没有 ON CONFLICT 处理。
### 需要修复
1. **建表时**`field.unique` 应创建 `CREATE UNIQUE INDEX`
2. **INSERT 时**:需要检查 unique 字段的值是否已存在(或使用 `ON CONFLICT`
3. **REST API 层**`PluginDataService::create` 也需要同样的检查
修复方案(`dynamic_table.rs` 建表部分):
```rust
let idx_sql = if field.unique {
format!(
"CREATE UNIQUE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" \
(\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
)
} else {
format!(
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" \
(\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
)
};
```
INSERT 前需要做存在性检查,或使用事务内 SELECT + INSERT 模式。
**结论:唯一性约束是必须修复的 bug否则 CRM 的 customer.code 无法保证唯一。** 改动量小但重要性高。
---
## 5. 前端通用组件实现量
### 5.1 tree树形页面
**用途**组织架构树、客户层级树parent_id 关系)
**Ant Design 基础组件**`Tree` / `DirectoryTree` / `TreeSelect`
**开发量**2-3 天
- Schema 解析:从 manifest 中识别 `parent_id` 字段作为树形关系
- 数据加载:一次性加载全量数据,前端组装树结构
- CRUD 操作:内联新增/编辑/删除节点
- 拖拽排序(可选)
**关键依赖**:需要 `PluginCRUDPage` 支持全量加载模式(不分页),当前 `data_service.rs``list` 只支持分页。
### 5.2 graph关系图
**用途**客户关系图customer_relationship 实体)
**Ant Design 基础组件**:没有内置 graph 组件
**需要引入的库**`@ant-design/charts`G6 封装)或 `reactflow`
**开发量**5-8 天
- 图数据转换:将 customer + customer_relationship 数据转换为 nodes/edges
- 图布局算法:力导向布局 / 层次布局
- 交互:节点点击查看详情、拖拽、缩放
- 工具栏:放大/缩小/导出
**技术风险**:高。图可视化是这 5 种页面类型中最复杂的。需要考虑性能100+ 节点时的渲染)。
### 5.3 timeline时间线
**用途**:沟通记录时间线、客户跟进历史
**Ant Design 基础组件**`Timeline` / `Steps`
**开发量**1-2 天
- 数据排序:按 `occurred_at` 排序
- 渲染:不同 `type`(电话/邮件/会议)显示不同图标和颜色
- 交互:点击展开详情
**最简单的页面类型**Ant Design Timeline 直接可用。
### 5.4 tabs标签页嵌套
**用途**:客户详情页包含多个标签(基本信息/联系人/沟通记录/标签/关系)
**Ant Design 基础组件**`Tabs`
**开发量**3-5 天
- Manifest 扩展:定义 tabs 嵌套结构
- 组件组合:每个 tab 内部是一个子页面(可能是 crud/timeline/graph
- 数据关联:子页面需要接收父实体的 ID 作为过滤条件
- 路由:需要处理嵌套路由或状态切换
**核心挑战**tabs 嵌套需要 manifest 结构支持 `children``tabs` 字段,并且在运行时动态组合不同类型的页面组件。
### 5.5 dashboard仪表盘
**用途**CRM 概览(客户总数、本月新增、跟进中、转化率等)
**Ant Design 基础组件**`Statistic` / `Card` / `@ant-design/charts`
**开发量**5-7 天
- 统计卡片:数值展示 + 趋势箭头
- 图表:柱状图/折线图/饼图(需要聚合查询 API
- 筛选器:时间范围、客户类型等
- 实时更新(可选)
**关键依赖**:需要后端提供聚合查询 APICOUNT/GROUP BY当前 `db_query` 不支持聚合。`@ant-design/charts` 需要作为新依赖安装。
### 汇总
| 页面类型 | 开发量 | 技术风险 | Ant Design 支持 |
|----------|--------|----------|-----------------|
| tree | 2-3 天 | 低 | Timeline/Tree 组件直接可用 |
| graph | 5-8 天 | 高 | 需要引入第三方库 |
| timeline | 1-2 天 | 低 | Timeline 组件直接可用 |
| tabs | 3-5 天 | 中 | Tabs 可用,但需要组合其他页面类型 |
| dashboard | 5-7 天 | 中 | Statistic/Card 可用,图表需第三方库 |
**总计**16-25 天(约 3-5 周)的前端开发量。建议分两期:
- 第一期1-2 周timeline + tree + tabsCRM 核心需要)
- 第二期2-3 周dashboard + graph锦上添花
---
## 6. manifest.ui.pages 解析
### 当前结构
`manifest.rs` 第 94-109 行:
```rust
pub struct PluginUi {
pub pages: Vec<PluginPage>,
}
pub struct PluginPage {
pub route: String,
pub entity: String,
pub display_name: String,
pub icon: String,
pub menu_group: Option<String>,
}
```
### 需要扩展为
```rust
pub struct PluginPage {
pub route: String,
pub entity: String,
pub display_name: String,
pub icon: String,
pub menu_group: Option<String>,
// 新增
pub page_type: Option<String>, // "crud" | "tree" | "graph" | "timeline" | "tabs" | "dashboard"
pub tabs: Option<Vec<PluginTab>>, // tabs 嵌套
pub field_mappings: Option<HashMap<String, String>>, // 字段映射配置
pub filters: Option<Vec<PluginFilter>>, // 过滤器配置
}
pub struct PluginTab {
pub label: String,
pub entity: String,
pub page_type: Option<String>,
pub filters: Option<Vec<PluginFilter>>,
}
pub struct PluginFilter {
pub field: String,
pub source: String, // "url_param" | "parent_entity" | "fixed"
pub value: Option<String>,
}
```
### 影响范围
1. **manifest.rs**:扩展 `PluginPage` 结构体 — 改动量小,只新增字段
2. **service.rs**`install` 方法中需要将新的 manifest 数据存入 `plugin_entity.schema_json` — 无需改动(已序列化完整 entity 定义)
3. **前端 plugin store**`plugin.ts``PluginMenuItem` 需要增加 `pageType` 字段 — 改动量小
4. **前端路由**`App.tsx` 需要根据 `page_type` 渲染不同组件 — 中等改动
5. **前端 PluginCRUDPage**:需要重构为通用页面路由器,根据 page_type 分发到不同组件
**结论manifest 结构扩展的改动量不大**Rust 端约 30 行),但前端需要重构路由分发逻辑。对现有代码的影响是可控的,因为都是新增字段(`Option<>`),不破坏现有 manifest 解析。
---
## 7. 权限与数据隔离
### 当前权限模型
`data_handler.rs` 中的权限检查(第 35、80、116、143、181 行):
- 列表/详情:`require_permission(&ctx, "plugin.list")`
- 创建/更新/删除:`require_permission(&ctx, "plugin.admin")`
**所有插件数据共用 `plugin.list` 和 `plugin.admin` 两个权限码。**
`m20260417_000034_seed_plugin_permissions.rs` 也只种子了这两个权限。
### CRM 需要 8 个权限码
CRM 设计的权限码如 `crm.customer.list``crm.customer.create` 等。这些与当前的 `plugin.list`/`plugin.admin` 是两套完全不同的权限体系。
### check-permission Host API
`host.rs` 第 167-169 行:
```rust
fn check_permission(&mut self, permission: String) -> Result<bool, String> {
Ok(self.permissions.contains(&permission))
}
```
这个 API 检查的是当前用户的权限列表。`permissions` 来自 `ExecutionContext.permissions``engine.rs` 第 68 行),是外部传入的用户权限列表。
**关键问题CRM 的 8 个权限码在数据库中不存在。**
当前权限系统的流程:
1. 权限定义在 `permissions` 表中(通过 migration seed 或 admin API 创建)
2. 通过 `role_permissions` 表分配给角色
3. 用户登录时JWT 中间件从角色关联加载权限列表
CRM 插件的 `crm.customer.list` 等权限需要:
1. 在插件安装时,动态 INSERT 到 `permissions`
2. 分配给适当角色
3. 这样用户请求时 JWT 中间件才能加载这些权限
### 需要的改造
1. **安装时自动注册权限**`service.rs``install` 方法需要遍历 manifest 中的 `permissions` 列表INSERT 到 `permissions`
2. **REST API 权限映射**`data_handler.rs` 的权限检查需要从固定 `plugin.list`/`plugin.admin` 改为动态检查——基于 manifest 中声明的权限码
3. **卸载时清理权限**`uninstall` 方法需要清理 `permissions` 表和 `role_permissions` 表中的插件权限
**当前 manifest 已支持 permissions 声明**`manifest.rs` 第 112-118 行的 `PluginPermission`),但 `service.rs``install` 方法没有将其写入 `permissions` 表。
**结论:权限系统需要中等改造。** 核心改动点在 `service.rs` 的 install/uninstall 方法中增加权限 CRUD以及 `data_handler.rs` 中从固定权限改为动态权限检查。
---
## 8. 整体实施风险评估
### 高风险
#### R1db-query Host API 不可用(风险等级:高)
**问题**:当前 `db_query` 依赖预填充,但 `execute_wasm` 中没有预填充逻辑。CRM 插件在 WASM 内部无法执行关联查询。
**影响**WASM 内的 `handle_event` 无法查询关联数据。例如,收到 `workflow.task.completed` 事件后,无法查询关联的客户记录。
**缓解方案**
- 短期CRM 的关联查询全部走前端 REST APIWASM 内只做简单的 `db_insert`/`db_update`
- 长期:改造 `db_query` 为实时查询模式(需要在 `spawn_blocking` 中支持异步 DB 调用)
**建议**CRM 的复杂查询(关联、聚合)不应在 WASM 内完成。WASM 插件的 `handle_event` 应限于简单的状态变更,复杂查询由前端直接调用 REST API。
#### R2唯一索引 Bug风险等级
**问题**`dynamic_table.rs``unique` 字段只创建了普通索引不是唯一索引。INSERT 时也没有冲突检查。
**影响**`customer.code` 无法保证唯一性,可能插入重复数据。
**修复量**:约 20 行代码改动。
#### R3插件权限未注册到数据库风险等级
**问题**manifest 声明了 permissions但 install 时没有写入 permissions 表。
**影响**CRM 的 8 个权限码在数据库中不存在check-permission 永远返回 falseJWT 中间件也无法加载这些权限。
**修复量**:约 50 行代码改动install/uninstall 方法增加权限 CRUD
### 中风险
#### R4REST API 不支持过滤查询(风险等级:中)
**问题**`PluginDataListParams` 只有分页参数不支持字段过滤。CRM 需要按 `customer_id` 过滤联系人等场景。
**修复量**:约 80 行代码改动(后端 filter 解析 + SQL 构建 + 前端传参)。
#### R5前端 tabs 嵌套实现复杂度(风险等级:中)
**问题**tabs 页面类型需要支持动态组合不同子页面类型,且子页面需要从父页面获取过滤参数。这涉及组件设计模式的选择。
**缓解方案**:先实现基本的 tabs + crud 组合graph/timeline 作为子页面类型后续迭代。
#### R6前端页面类型路由分发风险等级
**问题**:当前 `App.tsx` 只有一个 `PluginCRUDPage` 路由。需要根据 manifest 中的 `page_type` 动态选择渲染组件。
**修复量**:约 100 行代码改动(新增路由分发组件 + 各页面类型组件)。
### 低风险
#### R7Fuel 限制(风险等级:低)
1000 万 fuel 对 CRM 插件绰绰有余。无需改动。
#### R8manifest 扩展(风险等级:低)
新增的 `page_type``tabs``filters` 字段都是 `Option<>`,不破坏现有解析。改动量约 30 行。
---
## 实施建议
### 必须先修复的 3 个问题1-2 天)
1. **修复唯一索引**`dynamic_table.rs``field.unique` 创建 `CREATE UNIQUE INDEX`
2. **权限注册**`service.rs``install` 方法将 manifest.permissions 写入 permissions 表
3. **REST API 过滤**`PluginDataListParams` 增加 filter 参数,`build_query_sql` 支持条件过滤
### 分期实施计划
#### 第一期:最小可用 CRM2-3 周)
- 5 个 JSONB 实体customer/contact/communication/customer_tag/customer_relationship
- plugin.toml manifest 定义
- WASM 插件 Rust crateinit + on_tenant_created + handle_event 基础逻辑)
- 前端 CRUD 页面(复用已有 PluginCRUDPage
- 前端 tabs 页面类型(客户详情页的基本信息+联系人+沟通记录)
- 前端 timeline 页面类型(沟通记录时间线)
- REST API filter 支持
#### 第二期增强功能2-3 周)
- 前端 tree 页面类型(客户层级树)
- 前端 dashboard 页面类型CRM 概览统计)
- 后端聚合查询 APICOUNT/GROUP BY
- graph 页面类型(客户关系图)
- 高级搜索/筛选
#### 第三期优化打磨1-2 周)
- WASM 内部 db-query 实时查询改造
- 批量操作支持
- 数据导入导出
- 性能优化(大量客户数据场景)
---
## 总结
CRM 插件在当前 WASM 插件系统上的实现**整体可行**,但有几个前提条件需要先满足:
1. **必须修复**:唯一索引 bug、权限注册缺失、REST API 过滤能力缺失
2. **核心路径**CRM 的数据操作通过 REST API 而非 WASM 内的 db-query 完成(这是正确的架构选择)
3. **WASM 角色定位**WASM 负责生命周期钩子init/on_tenant_created/handle_event不负责复杂查询和聚合
4. **前端是主要工作量**5 种新页面类型中graph 和 dashboard 开发量最大,建议分两期交付
最大的技术风险不在 WASM 运行时本身(它已被验证),而在于周边配套设施(权限、过滤、前端组件)的完善程度。

View File

@@ -0,0 +1,512 @@
# CRM 插件 UI 架构审查报告
> 审查范围CRM 插件 manifest 配置驱动 UI 方案
> 审查人ArchitectUX
> 日期2026-04-15
---
## 一、现状总结
读完代码后,当前的架构清晰度很高:
- **后端**`PluginManifest`manifest.rs定义了 `PluginPage`,但只有 `route/entity/display_name/icon/menu_group` 五个字段,没有 `type` 概念。所有插件页面都走同一条路径渲染。
- **前端路由**`App.tsx` 只注册了一条通配路由 `/plugins/:pluginId/:entityName`,全部指向 `PluginCRUDPage`
- **CRUD 页面**`PluginCRUDPage` 有基础的表格+分页+Modal表单+删除,但没有搜索、筛选、排序能力。表格硬编码只展示前 5 个字段。
- **数据层**`DynamicTableManager` 把所有字段塞进一个 JSONB `data` 列,查询能力有限(不支持按 JSONB 内字段筛选/排序/搜索)。`PluginDataListParams` 只有 `page/page_size/search`,没有 filter 参数。
- **插件菜单**`PluginStore.refreshMenuItems` 按 entity 平铺生成菜单项,不支持嵌套/分组/多页面类型。
这意味着 CRM 方案中设想的 `type="tabs"/tree/graph/timeline` 在 manifest 结构、后端 API、前端路由、前端渲染器四个层面都**没有基础设施**。下面逐项给出具体建议。
---
## 二、逐项审查与建议
### 2.1 CRUD 页面增强:筛选、搜索、标签过滤
**问题**CRM 客户列表需要按类型/等级/地区/行业/状态筛选,按名称/编码模糊搜索,按标签过滤。现有 `PluginField` 没有"是否可搜索/可筛选"的概念,后端也没有 filter 参数。
**建议**:分两层扩展。
**Manifest 层** -- 在 `PluginField` 上新增配置:
```toml
[[schema.entities.fields]]
name = "customer_type"
field_type = "string"
display_name = "客户类型"
ui_widget = "select"
filterable = true # 新增:出现在筛选栏
options = [
{ label = "企业", value = "enterprise" },
{ label = "个人", value = "individual" },
]
[[schema.entities.fields]]
name = "name"
field_type = "string"
display_name = "客户名称"
searchable = true # 新增:出现在搜索框
```
新增字段语义:
- `searchable: bool` -- 该字段参与模糊搜索(后端用 `data->>'name' ILIKE '%keyword%'`
- `filterable: bool` -- 该字段出现在筛选栏,根据 `ui_widget` 自动选择筛选控件select 用 Selectdate 用 DatePicker 等)
- `sortable: bool` -- 该字段可排序(后端用 `ORDER BY data->>'field'`
**后端层** -- 扩展 `PluginDataListParams`
```rust
pub struct PluginDataListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub search: Option<String>, // 已有,保持
pub filters: Option<String>, // 新增JSON 格式 {"customer_type":"enterprise","level":"A"}
pub sort_by: Option<String>, // 新增:字段名
pub sort_order: Option<String>, // 新增asc/desc
}
```
`DynamicTableManager` 中根据 searchable/filterable 字段动态拼接 WHERE 子句和 ORDER BY。JSONB 字段的索引需要用 GIN 索引或 expression index 支持。
**前端层** -- `PluginCRUDPage` 增加筛选栏区域:
从 schema 中提取 `filterable=true` 的字段,在表格上方渲染筛选控件。从 `searchable=true` 的字段提取字段名,传给搜索框的 placeholder 提示(如"搜索客户名称/编码")。
**评估**:这个方案是渐进式的,不需要引入新的页面类型,只需扩展现有 crud 的能力。实现优先级最高。
---
### 2.2 客户详情页
**问题**CRM 的客户详情需要展示基本信息+关联联系人+沟通记录时间线+关系图谱。当前没有"详情页"概念。
**建议**:新增 `detail` 页面类型,作为 CRUD 的行级扩展,不需要独立路由。
**Manifest 设计**
```toml
[[ui.pages]]
type = "crud"
label = "客户列表"
entity = "customer"
detail = { # 新增:点击行进入详情
layout = "tabs", # 详情页内部布局用 tabs 组织
sections = [
{ type = "fields", label = "基本信息", fields = ["name","code","customer_type","level","industry","region","status"] },
{ type = "crud", label = "联系人", entity = "contact", parent_field = "customer_id" },
{ type = "timeline", label = "沟通记录", entity = "communication", date_field = "created_at", content_field = "content" },
{ type = "graph", label = "关系图谱", entity = "customer_relationship", source_field = "from_customer_id", target_field = "to_customer_id" },
]
}
```
**实现方式**
1. 表格行增加"查看"按钮(或行点击事件),打开一个 Drawer/新页面
2. 路由:`/plugins/:pluginId/:entityName/:id` -- 复用现有路由增加可选 `:id`
3. 详情页是一个容器组件,根据 `detail.sections` 配置渲染 tabs每个 tab 内部递归使用已有的渲染器crud/tree/timeline/graph
4. "基本信息" tab 用 Ant Design `Descriptions` 组件渲染,`fields` 指定显示哪些字段及顺序
**为什么不用单独的 `tabs` 页面类型包裹**tabs 作为顶级页面类型适合管理视角(如"客户管理"入口),但详情页是某个具体记录的上下文视图,两者是不同层级。顶级 tabs 是菜单入口,详情页 tabs 是数据上下文。
**优先级**高。CRM 的核心体验就是从列表进入详情。建议先实现 `fields` + `crud` 两种 section 类型,`timeline``graph` 作为后续迭代。
---
### 2.3 关系图谱的交互设计
**问题**graph 类型需要节点点击、拖拽、缩放、关系筛选等交互。如何配置控制?
**建议**graph 页面是所有新类型中复杂度最高的,建议分两个阶段。
**第一阶段 -- 基础配置**
```toml
type = "graph"
entity = "customer_relationship"
source_entity = "customer" # 节点数据来源
source_field = "from_customer_id" # 关系起点
target_field = "to_customer_id" # 关系终点
edge_label_field = "relationship_type" # 边上的标签
node_label_field = "name" # 节点上的标签
node_color_field = "customer_type" # 按字段值区分节点颜色
interactions = ["click", "zoom", "drag"] # 启用的交互,默认全部
on_click = "drawer" # 点击节点打开 drawer 查看详情
```
**第二阶段 -- 高级配置**CRM 专属,不急于实现):
```toml
[[ui.pages.graph.filters]]
field = "relationship_type"
label = "关系类型"
multiple = true
[[ui.pages.graph.node_styles]]
value = "enterprise"
color = "#2563EB"
size = 40
[[ui.pages.graph.node_styles]]
value = "individual"
color = "#059669"
size = 30
```
**技术选型**:建议使用 Ant Design 内置能力 + 一个轻量图谱库(如 antv/G6 或 react-force-graph。G6 是 AntV 生态的,和 Ant Design 风格一致,社区活跃,配置驱动友好。
**优先级**:中。图谱是 CRM 的差异化功能,但实现成本高。第一阶段可以先做一个静态展示(节点+边+基础交互),第二阶段再做筛选和样式。
---
### 2.4 个人/企业客户的差异化表单
**问题**:同一个 customer 实体,企业客户显示 credit_code个人客户显示 id_number。
**建议**:在 `PluginField` 上新增 `visible_when` 条件表达式。
```toml
[[schema.entities.fields]]
name = "customer_type"
field_type = "string"
display_name = "客户类型"
ui_widget = "select"
required = true
options = [
{ label = "企业", value = "enterprise" },
{ label = "个人", value = "individual" },
]
[[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 == 'individual'" # 条件显示
```
**`visible_when` 语法**:采用简单表达式,前端解析执行。
支持的表达式类型:
- `field_name == 'value'` -- 字段等于某值
- `field_name != 'value'` -- 字段不等于某值
- `field_name in ['a','b']` -- 字段在列表中
前端实现:`Form.useWatch` 监听 `customer_type` 值变化,动态显示/隐藏字段。Ant Design Form 的 `shouldUpdate``noStyle` + 条件渲染即可实现。
**后端无需改动** -- `visible_when` 是纯前端概念,后端只管存 data JSONB不做校验。
**评估**:实现成本低,收益高。`visible_when` 是通用能力,不只服务于 CRM任何需要条件表单的插件都能用到。优先级高。
---
### 2.5 树形页面
**问题**:客户层级树需要展开/折叠、拖拽排序、点击节点查看详情/子节点。交互够通用吗?
**建议**:树的交互拆分为"必选"和"可选"两层。
**Manifest 配置**
```toml
type = "tree"
entity = "customer"
id_field = "id"
parent_field = "parent_id"
label_field = "name"
icon_field = "customer_type" # 可选:根据字段值显示不同图标
default_expand_level = 2 # 默认展开层级
draggable = false # CRM 客户层级通常不允许随意拖拽
on_click = "drawer" # 点击节点drawer 查看 | crud 子表格 | 路由跳转
actions = ["add_child", "edit", "delete"] # 节点右键/操作按钮
```
**交互拆解**
| 交互 | 必选 | 说明 |
|------|------|------|
| 展开/折叠 | 是 | Ant Design Tree 原生支持 |
| 点击查看详情 | 是 | 打开 Drawer 或跳转 |
| 异步加载子节点 | 是 | 按需加载,避免一次性拉取大树 |
| 搜索节点 | 是 | 过滤树节点,高亮匹配 |
| 拖拽排序 | 否 | `draggable = true` 时启用,默认关闭 |
| 右键菜单 | 否 | `actions` 配置后启用 |
**后端要求**:树形数据需要后端支持两种查询模式:
1. 一次性返回所有节点(小数据量)
2. 按父节点 ID 分批加载子节点(大数据量)
需要在 `PluginDataListParams` 增加 `parent_id``root_only` 参数。
**通用性评估**:足够通用。组织架构、部门、产品分类、地区层级都能用同一套配置。优先级中高。
---
### 2.6 时间线视图与 CRUD 列表的切换
**问题**:时间线和 CRUD 是同一数据的两种视图,用户如何切换?
**建议**:在 `crud` 页面类型上增加 `views` 配置,而不是用 tabs 包裹。
```toml
type = "crud"
entity = "communication"
label = "沟通记录"
[crud.views]
default = "table" # 默认视图
alternatives = [
{ type = "timeline", date_field = "created_at", content_field = "content", title_field = "subject" },
]
```
**前端实现**表格右上角增加视图切换按钮组Ant Design `Segmented``Radio.Group`),类似文件管理器的列表/网格切换。切换后保持筛选条件和分页状态。
**为什么不用 tabs**
- tabs 是语义上不同的内容(客户列表 vs 客户层级 vs 关系图谱),各自有独立的数据加载逻辑
- 视图切换是**同一数据的不同呈现**,共享筛选、分页、搜索状态
- 放在 CRUD 内部可以避免用户在 tab 间切换时丢失筛选上下文
**优先级**中。时间线是沟通记录的最佳展示方式但实现简单Ant Design Timeline 组件即可),可以在 CRUD 增强后顺手实现。
---
### 2.7 配色和视觉一致性
**问题**:新增页面类型需与现有内置模块风格一致。
**现状分析**
- 主题系统已有亮/暗模式(`App.tsx``themeConfig` / `darkThemeConfig`),使用 Ant Design 的 `ConfigProvider` 全局注入
- 主色 `#4F46E5`Indigo圆角 `8px`,控件高度 `36px`
- 内置模块(如 Users使用 `theme.useToken()` 获取设计令牌
**建议**
1. **所有新增页面类型必须使用 Ant Design 组件** -- 树用 `Tree`/`DirectoryTree`,时间线用 `Timeline`,标签页用 `Tabs`,详情用 `Descriptions`,抽屉用 `Drawer`。不要引入自定义组件。
2. **图谱组件是唯一例外** -- Ant Design 没有图谱组件,需要引入第三方库。选型标准:
- 支持主题定制(节点/边颜色走 Ant Design token
- 节点样式默认使用 `token.colorPrimary` / `token.colorSuccess`
3. **插件页面类型的容器样式统一** -- 所有页面类型共享同一个外层容器组件:
```tsx
// 插件页面通用容器
function PluginPageContainer({ title, toolbar, children }) {
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>{title}</h2>
<Space>{toolbar}</Space>
</div>
{children}
</div>
);
}
```
这与现有 `PluginCRUDPage` 的布局结构一致24px padding、flex header、标题+工具栏)。
4. **不要在插件 manifest 中暴露颜色配置** -- 颜色由基座主题系统统一管控。插件如果需要区分视觉(如节点类型着色),使用语义化的 token 名称(`success/error/warning`)而非具体色值。
---
## 三、Manifest 结构重构建议
当前 `PluginPage` 结构过于简单,无法支撑多种页面类型。建议重构为:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginPage {
/// CRUD 表格页面
Crud(CrudPageConfig),
/// 树形页面
Tree(TreePageConfig),
/// 关系图谱页面
Graph(GraphPageConfig),
/// 标签页容器
Tabs(TabsPageConfig),
/// 仪表盘
Dashboard(DashboardPageConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrudPageConfig {
pub route: String,
pub entity: String,
pub display_name: String,
pub icon: Option<String>,
pub menu_group: Option<String>,
pub detail: Option<DetailConfig>,
pub views: Option<ViewsConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreePageConfig {
pub route: String,
pub entity: String,
pub display_name: String,
pub icon: Option<String>,
pub id_field: String,
pub parent_field: String,
pub label_field: String,
pub draggable: Option<bool>,
pub on_click: Option<String>,
pub default_expand_level: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphPageConfig {
pub route: String,
pub entity: String,
pub display_name: String,
pub icon: Option<String>,
pub source_entity: String,
pub source_field: String,
pub target_field: String,
pub edge_label_field: Option<String>,
pub node_label_field: Option<String>,
pub on_click: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TabsPageConfig {
pub route: String,
pub display_name: String,
pub icon: Option<String>,
pub menu_group: Option<String>,
pub tabs: Vec<TabItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TabItem {
pub label: String,
#[serde(flatten)]
pub page: Box<PluginPage>,
}
```
`PluginField` 扩展:
```rust
pub struct PluginField {
// ... 现有字段 ...
pub searchable: Option<bool>,
pub filterable: Option<bool>,
pub sortable: Option<bool>,
pub visible_when: Option<String>,
pub column_width: Option<u32>,
pub hidden_in_table: Option<bool>,
pub hidden_in_form: Option<bool>,
}
```
---
## 四、前端架构建议
### 4.1 路由改造
当前路由 `/plugins/:pluginId/:entityName` 需要改为基于 manifest 的动态路由:
```
/plugins/:pluginId/*pagePath # pagePath 对应 manifest 中的 route
```
`PluginCRUDPage` 重构为 `PluginPageRenderer`,根据 manifest 的 `type` 字段分发到不同的渲染器:
```
PluginPageRenderer
-> PluginCrudPage (type="crud")
-> PluginTreePage (type="tree")
-> PluginGraphPage (type="graph")
-> PluginTabsContainer (type="tabs")
-> PluginDashboardPage (type="dashboard")
```
### 4.2 组件文件结构
```
apps/web/src/
plugins/ # 插件渲染器
PluginPageRenderer.tsx # 入口分发器
PluginPageContainer.tsx # 通用容器padding、标题、工具栏
crud/
PluginCrudPage.tsx # CRUD 表格+表单(重构自现有)
CrudFilterBar.tsx # 筛选栏
CrudSearchBar.tsx # 搜索栏
CrudDetailDrawer.tsx # 详情抽屉
CrudFormFields.tsx # 动态表单字段(含 visible_when
tree/
PluginTreePage.tsx # 树形页面
graph/
PluginGraphPage.tsx # 图谱页面
timeline/
PluginTimelineView.tsx # 时间线视图(作为 CRUD 的备选视图)
tabs/
PluginTabsContainer.tsx # 标签页容器
shared/
manifestParser.ts # manifest 解析工具
filterUtils.ts # 筛选参数构建
```
### 4.3 PluginStore 改造
`refreshMenuItems` 需要从 manifest 的 `ui.pages` 解析菜单结构,支持嵌套:
```typescript
interface PluginMenuItem {
key: string; // 路由路径
icon: string;
label: string;
pluginId: string;
pageType: string; // crud/tree/graph/tabs/dashboard
menuGroup?: string;
children?: PluginMenuItem[]; // tabs 类型的子页面
}
```
---
## 五、实施优先级
| 优先级 | 内容 | 理由 |
|--------|------|------|
| P0 | Manifest 结构重构PluginPage enum + PluginField 扩展) | 所有后续工作的基础 |
| P0 | CRUD 增强searchable/filterable/sortable + 后端 filter 支持 | CRM 最高频操作 |
| P0 | visible_when 条件表单 | 个人/企业客户差异化核心需求 |
| P1 | 详情页detail 配置 + Drawer 渲染 + fields/crud section | CRM 核心体验 |
| P1 | 标签页容器tabs 类型) | CRM 入口页面需要 |
| P1 | 前端路由重构PluginPageRenderer 分发) | 支持多页面类型 |
| P2 | 树形页面 | 客户层级,通用性高 |
| P2 | 时间线视图(作为 CRUD 备选视图) | 沟通记录展示 |
| P3 | 关系图谱(基础版) | 差异化功能,实现成本高 |
| P4 | 关系图谱(高级版:筛选、样式) | 锦上添花 |
---
## 六、风险提示
1. **JSONB 查询性能**:当数据量大时,`data->>'field' ILIKE '%keyword%'` 性能堪忧。建议在安装插件时,根据 `searchable=true` 的字段自动创建 GIN 索引(`gin((data->>'name'))` 或表达式索引)。
2. **图谱库体积**G6 完整包约 1MB+。如果图谱不是核心功能,考虑用 antv/L7 或更轻量的 react-force-graph~200KB或者按需加载dynamic import
3. **visible_when 表达式安全**:前端解析用户配置的表达式需要沙箱化,防止恶意 manifest 注入。用简单的字符串匹配(正则解析 `field == 'value'`),不要用 `eval``new Function`
4. **向后兼容**:现有的 `PluginPage`flat struct需要保持兼容。可以在反序列化时同时支持新旧两种格式或者在 manifest 版本号上做区分。
---
## 七、总结
CRM 插件的"完全配置驱动"方向是正确的。核心设计原则:
1. **Manifest 扩展而非前端代码** -- 所有 UI 差异通过 manifest 字段声明,前端渲染器统一处理
2. **渐进增强** -- 从 CRUD 增强开始,逐步增加新的页面类型,每一步都可交付
3. **通用性优先** -- visible_when、filterable、searchable、tree、detail 这些能力不是 CRM 专属的,任何插件都能受益
4. **后端能力先行** -- 前端能做多炫取决于后端能查多深。JSONB 的查询能力是整个方案的瓶颈

View File

@@ -0,0 +1,76 @@
# CRM 客户管理插件 — 实施计划
## Context
ERP 平台底座已完成 Phase 1-6 及 WASM 插件系统原型验证。本计划基于已批准的设计规格文档 `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` v1.1实施第一个行业插件——客户管理CRM
设计规格经历了三组专家架构师、UX 架构师、高级开发者)审查 + Spec 文档审查,所有 CRITICAL/HIGH 问题已修复。
## 关键决策回顾
- **插件形式**WASM 插件(非内置 crate
- **数据层**5 个 JSONB 动态表,通过 Host API 操作
- **UI 层**:完全配置驱动,插件不写前端代码,新 UI 能力沉淀到基座组件库
- **策略**:先修基座再做插件
- **Skill 沉淀**CRM 开发经验提炼为可复用 skill
## 实施步骤
### Step 1: 调用 writing-plans skill 创建详细实施计划
**操作**:调用 `writing-plans` skill基于设计规格文档 `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` 创建分 Phase 的详细实施计划。
**输入给 writing-plans 的上下文**
- 设计规格文档路径:`docs/superpowers/specs/2026-04-16-crm-plugin-design.md`
- 三期划分Phase 1基座增强→ Phase 2CRM 插件核心)→ Phase 3高级功能
- 专家审查报告路径:
- `plans/flickering-tinkering-pebble-agent-a409e20167941384b.md`(架构师)
- `plans/flickering-tinkering-pebble-agent-a785dda8d2f4eeebc.md`(高级开发者)
- `plans/flickering-tinkering-pebble-agent-ae1f1bf7d07977a2d.md`UX 架构师)
- Skill 沉淀要求CRM 完成后将开发经验提炼为可复用 skill
### Step 2: 按计划实施 Phase 1 — 基座增强
**前置条件**Phase 1 是 CRM 插件的前置条件,必须先完成。
Phase 1 的关键交付物:
1. Bug 修复:唯一索引(`dynamic_table.rs`)、权限注册(`service.rs`)、数据 Handler 动态权限(`data_handler.rs`
2. REST API 过滤/搜索/排序(`dynamic_table.rs` + `data_service.rs` + `data_handler.rs` + `dto.rs`
3. Manifest schema 扩展(`manifest.rs`
4. 前端 CRUD 增强 + detail 页面 + visible_when`PluginCRUDPage.tsx` + `PluginDetailPage.tsx` + `pluginData.ts`
5. 数据校验层
### Step 3: 按计划实施 Phase 2 — CRM 插件核心
**前置条件**Phase 1 全部完成 + 验收通过。
Phase 2 的关键交付物:
1. CRM WASM 插件 Rust 代码(`crates/erp-plugin-crm/`
2. CRM manifest`plugin.toml`
3. 前端 tree/timeline/tabs 通用页面组件
4. 侧边栏动态菜单集成
### Step 4: 按计划实施 Phase 3 — 高级功能
**前置条件**Phase 2 全部完成 + 验收通过。
Phase 3 的关键交付物:
1. 前端 graph 页面类型AntV G6
2. 前端 dashboard 页面类型
3. Host API 扩展db-count/db-aggregate
4. 关系图谱 + 统计概览
### Step 5: 提炼插件开发 Skill
**前置条件**Phase 2 完成后即可开始Phase 3 可并行)。
将 CRM 开发经验提炼为可复用 skill包含
- 插件开发流程指南
- Manifest 配置模板
- Rust 插件脚手架
- 可用页面类型清单
- 测试检查清单
## 验证方式
每个 Phase 完成后执行设计规格中的验收标准检查,具体验收项见 `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` 第 8 节。

View File

@@ -0,0 +1,242 @@
# CRM 插件平台标杆 — P0 基础能力设计
## Context
CRM 插件作为 ERP 平台的第一个行业插件,目前暴露了插件平台的多项基础能力缺口。本次设计的定位不是"CRM 功能最全",而是"插件平台能力最扎实"——通过 CRM 验证的每个能力都应被所有未来插件inventory、生产、财务等零改动复用。
对标一流 CRMSalesforce/HubSpot/Pipedrive的差距分析表明当前 CRM 更接近"客户通讯录+标签+图谱",距离可用 CRM 有显著差距。但这些差距中,最优先补的是**平台基础设施**,而非 CRM 业务功能。
## 设计决策记录
| 决策点 | 选择 | 理由 |
|--------|------|------|
| 目标定位 | 插件平台标杆 | 打磨通用能力,所有插件受益 |
| 推进节奏 | P0 基础先行 | 基础不扎实,上层的业务功能无法可靠运行 |
| 实体关系复杂度 | 全覆盖 (1:N/N:1/N:N/自引用) | CRM 和 inventory 都需要,一次到位 |
| 字段校验范围 | 完整套件 (6种) | 数据质量是所有插件的生命线 |
| 前端硬编码 | 全部通用化 | 第二个插件必须零改动可用 |
## P0-1: 实体关系声明 + ref_entity + 级联策略
### Manifest Schema 扩展
在 entity 下新增 `[[relations]]` 段:
```toml
[[schema.entities.relations]]
name = "contacts" # 关系名
target_entity = "contact" # 目标实体
type = "one_to_many" # one_to_many | many_to_one | many_to_many
foreign_key = "customer_id" # FK 字段
on_delete = "cascade_soft_delete" # cascade_soft_delete | set_null | restrict
display_field = "name" # 下拉框显示字段
# N:N 需要中间表
[[schema.entities.relations]]
name = "related_customers"
target_entity = "customer"
type = "many_to_many"
through_entity = "customer_relationship"
through_source_field = "from_customer_id"
through_target_field = "to_customer_id"
```
### 后端实现 (crates/erp-plugin/)
**关键文件:**
- `src/manifest.rs` — ManifestParser 新增 relations 解析
- `src/dynamic_table.rs` — 安装时存储关系到 entity metadata
- `src/data_service.rs` — 删除时执行级联策略,创建/更新时验证 FK
- `src/handler/data_handler.rs` — 错误响应格式
**级联策略执行流程:**
```
DELETE /plugins/{id}/{entity}/{rid}
→ 查询 entity 的所有 incoming relations (被引用的关系)
→ for each relation:
cascade_soft_delete → UPDATE child SET deleted_at=now() WHERE fk=rid
set_null → UPDATE child SET fk=NULL WHERE fk=rid
restrict → SELECT COUNT children, if >0 return 409 Conflict
→ 软删除目标记录
```
**FK 存在性校验:**
```
POST/PUT /plugins/{id}/{entity}
→ for each field with ref_entity:
SELECT EXISTS(SELECT 1 FROM plugin_xxx_{ref_entity} WHERE id=field_value AND deleted_at IS NULL)
→ 不存在则返回 400 + 具体字段错误
```
### 前端实现 (apps/web/)
**关键文件:**
- `src/pages/PluginCRUDPage.tsx` — 自动为 ref_entity 字段渲染 EntitySelect
- `src/pages/PluginDetailPage` — 自动渲染关联子实体内嵌列表
- `src/components/EntitySelect.tsx` — 增强支持 display_field 配置
- `src/api/plugins.ts` — schema 类型新增 relations
**详情页自动关联渲染:**
- 读取 entity 的 outgoing relations (one_to_many)
- 为每个 relation 渲染内嵌 CRUD 表格compact 模式,带 filter=fk:parent_id
- 级联删除前弹出确认("将同时删除 3 条联系人"
### CRM plugin.toml 改造
为 5 个实体补充 relations 声明:
- customer → contacts (1:N, cascade_soft_delete)
- customer → communications (1:N, cascade_soft_delete)
- customer → tags (1:N, cascade_soft_delete)
- customer → parent (N:1, set_null, 自引用)
- contact → communications (1:N, cascade_soft_delete)
---
## P0-2: 字段校验层
### Manifest Schema 扩展
在 field 下新增 `[validation]` 子结构:
```toml
[[schema.entities.fields]]
name = "phone"
field_type = "string"
[schema.entities.fields.validation]
pattern = "^1[3-9]\\d{9}$"
message = "请输入有效的手机号码"
min_length = 11
max_length = 11
[[schema.entities.fields]]
name = "email"
field_type = "string"
[schema.entities.fields.validation]
pattern = "^[\\w.-]+@[\\w.-]+\\.\\w+$"
message = "请输入有效的邮箱地址"
max_length = 254
```
### 后端校验器 (crates/erp-plugin/src/validation.rs — 新文件)
6 种校验器统一执行:
| 校验器 | 触发条件 | 错误格式 |
|--------|---------|---------|
| required | `field.required = true` | `{field}: 不能为空` |
| unique | `field.unique = true` | `{field}: 该值已存在` |
| pattern | `validation.pattern` regex match | `{field}: {validation.message}` |
| ref_exists | `field.ref_entity` FK 查询 | `{field}: 引用的{entity}不存在` |
| min_length / max_length | `validation.min_length / max_length` | `{field}: 长度必须在 {min}-{max} 之间` |
| min_value / max_value | `validation.min_value / max_value` | `{field}: 值必须在 {min}-{max} 之间` |
**执行位置:** `data_service.rs` 的 create/update 方法中,数据写入前统一调用。
**错误响应:**
```json
{
"success": false,
"error": "数据验证失败",
"details": [
{ "field": "phone", "message": "请输入有效的手机号码" },
{ "field": "customer_id", "message": "引用的客户不存在" }
]
}
```
### 前端校验生成
从 schema 自动生成 Ant Design Form rules
- `required``{ required: true, message: "..." }`
- `pattern``{ pattern: /regex/, message: "..." }`
- `min_length / max_length``{ min: n, max: n, message: "..." }`
### CRM plugin.toml 补充校验
- phone: pattern 手机号
- email: pattern 邮箱
- credit_code: pattern 统一社会信用代码 (18位)
- website: pattern URL
- customer_id: ref_entity = "customer" (FK 校验)
---
## P0-3: 前端去硬编码
### Dashboard 通用化
**文件:** `apps/web/src/pages/dashboard/dashboardConstants.tsx`, `PluginDashboardPage.tsx`
改造方案:
- 移除 `ENTITY_COLORS``ENTITY_ICONS` 硬编码映射
- 颜色自动分配: 8 色调色板按 entity 顺序循环
- 图标从 page schema 的 icon 字段读取
- 标题: `{manifest.name} 统计概览`,副标题: `{manifest.description}`
- Widget 定义从 page schema 的 widgets 数组读取
### Graph 通用化
**文件:** `apps/web/src/pages/plugins/graph/graphConstants.ts`
改造方案:
- 移除 `RELATIONSHIP_COLORS` 硬编码
- 关系类型标签从 field.options 读取 (已有 label 映射)
- 颜色用调色板按 option 顺序循环分配
- 未知类型 fallback 到灰色 + 原始 label
### CRUD 表格列可配置
**文件:** `PluginCRUDPage.tsx`
改造方案:
- manifest page 新增 `table_columns: ["name", "customer_type", "level", "status", "owner_id"]`
- 不声明则默认取前 8 个非 hidden 非 FK 字段
- 移除 `fields.slice(0, 5)` 硬编码
### 验证标准
> 换成 inventory 插件Dashboard/Graph/CRUD 应该零改动正确渲染。
---
## 关键文件清单
| 文件 | 改动类型 | 说明 |
|------|---------|------|
| `crates/erp-plugin/src/manifest.rs` | 修改 | 新增 relations + validation 解析 |
| `crates/erp-plugin/src/validation.rs` | 新建 | 校验引擎 |
| `crates/erp-plugin/src/data_service.rs` | 修改 | 集成级联策略 + 校验 |
| `crates/erp-plugin/src/dynamic_table.rs` | 修改 | 安装时存储关系元数据 |
| `crates/erp-plugin/src/handler/data_handler.rs` | 修改 | FK 校验错误格式 |
| `crates/erp-plugin-crm/plugin.toml` | 修改 | 补充 relations + 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` | 修改 | 可配置列数 |
| `apps/web/src/api/plugins.ts` | 修改 | 类型定义更新 |
---
## 验证方案
1. **编译检查**: `cargo check` 全 workspace 通过
2. **单元测试**: validation.rs 每种校验器独立测试
3. **集成测试**: Testcontainers 验证级联删除/FK 校验/unique 冲突
4. **功能验证**:
- 重新安装 CRM 插件,确认 5 个 relation 正确注册
- 删除客户 → 联系人/沟通记录/标签级联软删除
- 创建联系人 → customer_id 不存在时返回 400
- 手机号/邮箱格式不正确时返回校验错误
- Dashboard 切换 inventory 插件时正确渲染
5. **前端验证**: `pnpm dev` 启动后手动测试所有页面
---
## 输出产物
1. 设计规格文档: `docs/superpowers/specs/2026-04-18-crm-plugin-platform-p0-design.md`
2. 实施计划: 通过 writing-plans skill 生成
3. 知识库文档: 记录讨论过程和决策理由

View File

@@ -0,0 +1,169 @@
# CRM 插件审计修复计划
## Context
CRM 插件开发审计发现 4 个 CRITICAL、6 个 HIGH、7 个 MEDIUM 问题。本计划按优先级分 3 批修复,覆盖后端 Rust 和前端 React 两端。
## Batch 1 — CRITICAL阻断性不修功能不可用
### Fix 1: 后端权限 SQL 参数化
**文件**: `crates/erp-plugin/src/service.rs` (第 578-714 行)
- `register_plugin_permissions()`: 将 `format!()` + `Statement::from_string()` 改为 `Statement::from_sql_and_values()` + 参数化占位符 `$1, $2, ...`
- `unregister_plugin_permissions()`: 同上,两段 UPDATE 都参数化
- 需要新增一个辅助函数 `resolve_manifest_id(plugin_id: Uuid, db) -> AppResult<String>` 从 plugins 表查 manifest_json 获取 metadata.id供 Fix 2 使用)
### Fix 2: 后端权限码使用 manifest_id 而非 UUID
**文件**: `crates/erp-plugin/src/data_handler.rs` (第 19-25 行, 第 49/107/145/177/211/256/301 行)
- 每个 handler 先从 DB 查 manifest_id`let manifest_id = resolve_manifest_id(plugin_id, &state.db).await?;`
- `compute_permission_code` 改为接受 manifest_id
- 添加 `resolve_manifest_id` 辅助函数:查 plugins 表 → 解析 manifest_json → 提取 metadata.id
- 考虑在 PluginState 中缓存 manifest_id 映射(或直接在 data_service 层缓存)
**实现**: 将 `resolve_manifest_id` 放在 `data_service.rs` 中(与 `resolve_table_name` 同级handler 调用它。
### Fix 3: 4 个路由页面组件自行加载 schema
**文件**: 4 个页面组件 + `api/plugins.ts` + `api/pluginData.ts`
统一模式:每个组件通过 `useParams()` 获取路由参数,内部调用 `getPluginSchema()` 加载 schema从 schema 中提取所需数据。
**3a. PluginTabsPage.tsx**:
- 移除所有 propspluginId/label/tabs/entities改用 `useParams<{ pluginId: string; pageLabel: string }>()`
- 内部调用 `getPluginSchema(pluginId)` 获取 schema
-`schema.ui.pages` 中找到 type='tabs' 且 label 匹配 pageLabel 的页面
- 替换 `require()` 为顶层 ES `import`
- 移除不存在的 `enableSearch` prop 传递
**3b. PluginTreePage.tsx**:
- 移除 propspluginId/entity/idField/parentField/labelField/fields改用 `useParams<{ pluginId: string; entityName: string }>()`
- 从 schema 加载 entity 字段和页面配置tree 页面的 id_field/parent_field/label_field
**3c. PluginGraphPage.tsx**:
- 移除所有 props改用 `useParams<{ pluginId: string; entityName: string }>()`
- 从 schema 中找到 type='graph' 的页面配置
**3d. PluginDashboardPage.tsx**:
- 移除 propspluginId/entities改用 `useParams<{ pluginId: string }>()`
- 内部调用 `getPluginSchema(pluginId)` 获取所有 entities
### Fix 4: PluginPageSchema 补充 graph/dashboard 类型
**文件**: `apps/web/src/api/plugins.ts` (第 154-158 行)
扩展 union type
```typescript
| { type: 'graph'; entity: string; label: string; relationship_entity: string; source_field: string; target_field: string; edge_label_field: string; node_label_field: string }
| { type: 'dashboard'; label: string }
```
### Fix 5: api/pluginData.ts 补充 count/aggregate API
**文件**: `apps/web/src/api/pluginData.ts`
新增两个函数:
- `countPluginData(pluginId, entity, options?)``GET /plugins/{id}/{entity}/count`
- `aggregatePluginData(pluginId, entity, groupBy, filter?)``GET /plugins/{id}/{entity}/aggregate`
## Batch 2 — HIGH稳定性和正确性
### Fix 6: AbortController 防竞态
**文件**: 所有 5 个页面组件的 useEffect
为数据加载 useEffect 添加 AbortController
```typescript
useEffect(() => {
const abortController = new AbortController();
// ... async loadData 中检查 abortController.signal.aborted
return () => abortController.abort();
}, [deps]);
```
注意:当前 api client (axios) 不支持 AbortSignal 透传,简单方案是在 setState 前检查 `abortController.signal.aborted` 或使用一个 `mounted` flag。
### Fix 7: Dashboard 改用后端 aggregate API
**文件**: `apps/web/src/pages/PluginDashboardPage.tsx`
- 使用 `countPluginData()` 获取总数
- 使用 `aggregatePluginData()` 获取分组统计
- 移除全量循环加载逻辑
- 保留 fallback如果 aggregate API 失败,显示总数 + 提示
### Fix 8: fetchData 双重请求修复
**文件**: `apps/web/src/pages/PluginCRUDPage.tsx` (第 501-511 行)
搜索操作改为直接传参模式:
```typescript
onSearch={(value) => {
setSearchText(value);
setPage(1);
fetchData(1, { search: value }); // 直接传参
}}
```
修改 `fetchData` 签名允许覆盖搜索参数。
### Fix 9: Canvas 高 DPI 支持
**文件**: `apps/web/src/pages/PluginGraphPage.tsx` (第 114-118 行)
```typescript
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
```
## Batch 3 — MEDIUM建议修复
### Fix 10: 服务端排序替代前端排序
**文件**: `apps/web/src/pages/PluginCRUDPage.tsx`
- Table 的 `onChange` 回调捕获 sortField/sortOrder
- 传给 `fetchData` 作为 `sort_by`/`sort_order` 参数
- 移除列定义中的 `sorter: true`
### Fix 11: Canvas 暗色主题支持
**文件**: `apps/web/src/pages/PluginGraphPage.tsx`
从 CSS 变量读取主题色:
```typescript
const style = getComputedStyle(canvas);
const textColor = style.getPropertyValue('--antd-color-text') || '#333';
const lineColor = style.getPropertyValue('--antd-color-border') || '#999';
```
### Fix 12: schema 加载失败提示用户
**文件**: 所有页面组件的 `.catch(() => {})`
替换为 `message.warning('Schema 加载失败,部分功能不可用')`
### Fix 13: 后端 data_service 缓存优化
**文件**: `crates/erp-plugin/src/data_service.rs`
合并 `resolve_table_name``resolve_entity_fields` 为一个函数 `resolve_entity_info()`,减少数据库查询次数。
## 关键文件清单
| 文件 | 修改类型 |
|------|---------|
| `crates/erp-plugin/src/service.rs` | SQL 参数化 |
| `crates/erp-plugin/src/data_handler.rs` | manifest_id 查找 |
| `crates/erp-plugin/src/data_service.rs` | resolve_manifest_id + resolve_entity_info 缓存 |
| `apps/web/src/api/plugins.ts` | PluginPageSchema 扩展 |
| `apps/web/src/api/pluginData.ts` | count/aggregate API |
| `apps/web/src/pages/PluginTabsPage.tsx` | 自加载 schema |
| `apps/web/src/pages/PluginTreePage.tsx` | 自加载 schema |
| `apps/web/src/pages/PluginGraphPage.tsx` | 自加载 schema + DPI + 暗色 |
| `apps/web/src/pages/PluginDashboardPage.tsx` | 自加载 schema + 后端 aggregate |
| `apps/web/src/pages/PluginCRUDPage.tsx` | 搜索/排序修复 |
## 验证计划
1. `cargo check --workspace` 通过
2. `cargo test --workspace` 通过
3. `cd apps/web && pnpm build` 通过(验证 require → import 修复)
4. 手动验证:
- 侧边栏点击 CRM tabs 菜单 → 页面正常渲染
- CRUD 页面搜索/筛选/排序正常
- Tree 页面展示树形结构
- Graph 页面渲染图谱(高 DPI 清晰)
- Dashboard 页面显示统计

View File

@@ -0,0 +1,90 @@
# 审计问题修复计划(按优先级)
## Context
2026-04-18 系统全面审计发现多个问题(详见 `docs/audit-2026-04-18.md`)。当前系统因 Redis 未安装且限流策略为 fail-closed**所有 API 请求返回 429**,系统完全不可用。本计划按优先级逐步修复,恢复系统可用性。
---
## Fix 1: 限流中间件改为 fail-openP0-3
**问题**: Redis 未安装时fail-closed 策略拒绝所有请求,系统完全不可用。
**文件**: `crates/erp-server/src/middleware/rate_limit.rs`
**改动**:
- `apply_rate_limit()` 中 3 处 Redis 不可达时的处理,从返回 429 改为放行(调用 `next.run(req).await`
- 仅保留 tracing::warn 日志,不阻断业务
- 超限计数本身仍按原逻辑Redis 可达时正常限流,不可达时放行
**涉及行**: 约第 121-129 行、第 138-147 行、第 151-158 行
---
## Fix 2: 插件权限自动分配给 admin 角色P0-1
**问题**: 插件安装时注册权限到 `permissions` 表,但不分配给 admin 角色,导致 JWT 中缺少插件权限码,所有插件数据页面返回 403。
**文件**: `crates/erp-plugin/src/service.rs`
**改动**:
1. 新增 `grant_permissions_to_admin()` 函数(约第 766 行 `register_plugin_permissions` 之后):
- 查询 admin 角色 ID
- 查询当前插件的所有权限 ID按 manifest_id 前缀匹配)
- INSERT INTO role_permissions跳过已存在的记录ON CONFLICT DO NOTHING
- 参考 `crates/erp-auth/src/service/seed.rs` 中的 SQL 模式
2.`install()` 函数(约第 81 行)中,`register_plugin_permissions()` 调用之后,调用 `grant_permissions_to_admin()`
3.`enable()` 函数(约第 191 行)中也调用 `grant_permissions_to_admin()`,确保启用时权限也已分配
**参考文件**:
- `crates/erp-auth/src/service/seed.rs` — 已有的 admin 权限授予模式
- `crates/erp-server/migration/src/m20260417_000034_seed_plugin_permissions.rs` — 迁移中的 SQL 模式
- `crates/erp-plugin/src/handler/data_handler.rs:75``compute_permission_code()` 权限码格式
---
## Fix 3: CRM 插件 WASM 数据修复P0-2
**问题**: 数据库中存储的 CRM 插件 WASM 是错误的测试插件二进制110KB而非真正的 CRM 插件(~22KB。导致服务器重启后插件恢复失败。
**步骤**:
1. 重新编译 CRM 插件 WASM Component
2. 通过插件升级 API 上传正确的二进制
3. 验证插件恢复成功
**命令**:
```bash
cargo build -p erp-plugin-crm --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_crm.wasm -o target/erp_plugin_crm.component.wasm
```
---
## Fix 4: `/roles/permissions` 路由冲突P1
**问题**: `GET /api/v1/roles/permissions``GET /api/v1/roles/{id}` 路由拦截,`permissions` 被当作 UUID 解析失败。
**文件**: `crates/erp-auth/src/module.rs`
**改动**:
-`register_routes()` 中(约第 64-82 行),将 `/roles/permissions` 精确匹配路由放在 `:id` 参数路由**之前**
- Axum 路由匹配按注册顺序,精确路径优先于参数路径
---
## 验证计划
每个 Fix 完成后独立验证:
1. **Fix 1 验证**: 启动后端,`curl http://localhost:3000/api/v1/health` 返回 200不再 429
2. **Fix 2 验证**: 重新安装/启用 CRM 插件,用 admin 登录后访问 CRM 数据页面不再 403
3. **Fix 3 验证**: 重启后端,日志显示 `Plugins recovered: 1`(而非 0
4. **Fix 4 验证**: `curl -H "Authorization: Bearer <token>" http://localhost:3000/api/v1/roles/permissions` 返回权限列表(非 UUID 错误)
**最终验证**:
- `cargo check` 编译通过
- `cargo test --workspace` 全部通过
- 前端页面正常访问CRM 数据可操作

View File

@@ -0,0 +1,189 @@
# CRM 插件深度分析 & 多专家组头脑风暴
## Context
CRM 插件 (`erp-plugin-crm`) 是 ERP 平台的第一个行业业务插件,也是 WASM 插件系统的标杆验证案例。它已完成了设计规格中的全部 3 个阶段(共 24 个任务),包含 5 个实体、9 个权限、7 种页面类型。现在需要从深度和广度两个维度进行全方位审视,发现改进空间、架构风险和演进方向。
---
## 一、CRM 插件全景画像
### 1.1 架构定位
```
┌─────────────────────────────────────────────────┐
│ 前端 SPA │
│ PluginCRUDPage / Tree / Graph / Dashboard / │
│ Tabs / Detail / Admin — 全部 schema 驱动渲染 │
└────────────────────┬────────────────────────────┘
│ REST API (pluginData + plugins)
┌────────────────────▼────────────────────────────┐
│ Plugin Host (erp-plugin) │
│ service.rs → engine.rs → host.rs → dynamic_table│
│ data_service.rs → handler → module │
└────────────────────┬────────────────────────────┘
│ WIT 接口 (Host API 9 个函数)
┌────────────────────▼────────────────────────────┐
│ CRM WASM Guest (erp-plugin-crm) │
│ lib.rs (30行) — init/on_tenant_created/ │
│ handle_event 全部 no-op │
└──────────────────────────────────────────────────┘
```
### 1.2 数据模型 (5 实体)
| 实体 | 表名 | 字段数 | 用途 |
|------|------|--------|------|
| customer | plugin_erp_crm_customer | 15 | 客户主数据 (企业/个人) |
| contact | plugin_erp_crm_contact | 8 | 联系人 |
| communication | plugin_erp_crm_communication | 7 | 沟通记录 |
| customer_tag | plugin_erp_crm_customer_tag | 3 | 客户标签 |
| customer_relationship | plugin_erp_crm_customer_relationship | 4 | 客户关系 |
### 1.3 页面矩阵 (7 页面类型)
| 页面类型 | CRM 用途 | 复杂度 |
|----------|---------|--------|
| CRUD | 客户/联系人/沟通/标签/关系列表 | 中 (搜索/筛选/排序/视图切换) |
| Detail (Drawer) | 客户360度视图 | 高 (字段+嵌套CRUD+时间线) |
| Tree | 客户层级树 | 低 |
| Tabs | 客户管理入口 | 中 |
| Graph | 关系图谱 (Canvas手绘) | 高 |
| Dashboard | 统计概览 | 中 |
| Admin | 插件生命周期管理 | 中 |
### 1.4 权限模型 (9 权限)
两级权限体系:平台级 (`plugin.admin/list`) + 插件级 (`erp-crm.customer.list/manage` 等)
---
## 二、深度分析 — 六维度体检
### 2.1 架构健壮性 ⚠️
**优势:**
- Host-Guest 沙箱隔离Fuel 资源限制WASM panic 不影响主服务
- 延迟写入模式 (PendingOp) 保证事务原子性
- 乐观锁 + 软删除 + 多租户隔离
**风险:**
- **JSONB 外键完整性为零** — `contact.customer_id` 指向已删除客户不会被拦截
- **WASM Guest 价值极低** — CRM 插件 lib.rs 仅 30 行3 个生命周期钩子全是 no-op所有业务逻辑实际在 Host 侧的 data_service.rs
- **动态表 SQL 拼接风险** — 虽有 `sanitize_identifier()`,但 JSONB 查询构建器复杂度高
- **插件热升级策略缺失** — 版本升级时如何处理已有数据的 schema 变更?
### 2.2 数据完整性 ⚠️
**当前状态:**
- 无 DB 级外键约束JSONB 字段无法使用 PostgreSQL FK
- 无应用层 FK 校验data_service.rs 的 create/update 不检查关联实体是否存在)
- 删除客户时不级联处理关联的联系人/沟通记录/标签
- `customer.parent_id` 的循环引用无检测
### 2.3 查询性能 🟡
**已做优化:**
- GIN 索引覆盖 searchable 字段
- tenant_id 索引
- Redis 连接缓存 (响应 2.26s → 2ms)
**潜在瓶颈:**
- JSONB 内字段排序 (`ORDER BY data->>'field'`) 无法使用 B-tree 索引
- 聚合查询 (`aggregate`) 对大数据量可能慢
- 关系图谱需要全量加载 customer + customer_relationship前端 Canvas 渲染
- 无分页的树结构查询(前端全量加载后客户端构建)
### 2.4 安全合规 🔴
- **数据权限缺失** — 只有操作权限 (能否操作),无数据权限 (能看到谁的数据)
- **搜索注入** — search 参数直接拼入 SQL ILIKE需确认转义逻辑
- **权限 fallback 过宽** — `plugin.admin` 权限自动获得所有插件的所有操作权限
### 2.5 前端体验 🟡
**亮点:**
- 全 schema 驱动,零 CRM 专用前端代码
- visible_when 条件表单字段
- 时间线/表格视图切换
- Canvas 手绘关系图谱(无第三方库依赖)
- 嵌套 CRUD (Detail Drawer)
**不足:**
- 无看板视图(销售漏斗/客户跟进阶段)
- 无数据导入/导出功能
- 无批量操作
- 标签系统过于简单(无标签分组管理、无标签云)
- Dashboard 统计维度有限(只有计数+分组聚合)
- 树页面不支持拖拽排序
### 2.6 可扩展性 🟡
**平台能力沉淀:**
- 7 种通用页面类型可供未来插件复用
- 插件 manifest (TOML) schema 定义了完整的元数据规范
- 插件生命周期管理 (上传→安装→启用→禁用→卸载→清除)
**限制:**
- 页面类型有限 — 无日历、看板、甘特图、地图等业务常见页面
- WASM WIT 接口固定 — Host API 9 个函数,扩展需要修改 WIT + 双端重编译
- 插件间通信未实现 — 当前只能通过 EventBus 订阅系统事件,插件间无法直接交互
---
## 三、专家组头脑风暴议题
基于以上分析,建议从以下 6 个专家组视角展开头脑风暴:
### 专家组 1: 后端架构师
**焦点:** WASM 插件架构的真正价值在哪里?如何让 Guest 代码从 no-op 变成有意义的业务逻辑?
- Host API 的 db_query 为何不可用?如何修复?
- JSONB 动态表 vs 独立 schema 表的取舍
- 插件版本升级的数据迁移策略
- 延迟写入模式的局限性和改进
### 专家组 2: CRM 产品专家
**焦点:** 这个 CRM 能用吗?缺什么核心能力?
- 销售漏斗/商机管理缺失的影响
- 客户跟进提醒机制
- 数据导入/导出 (Excel)
- 客户画像/360度视图的完整性
- 与 ERP 其他模块 (进销存/财务) 的联动
### 专家组 3: 安全工程师
**焦点:** 数据权限、隔离性、合规风险
- 行级数据权限 (谁能看到哪些客户)
- JSONB 查询注入风险
- 插件 WASM 沙箱逃逸可能性
- 审计日志的完整性
- GDPR/数据隐私合规
### 专家组 4: 前端架构师
**焦点:** Schema 驱动 UI 的天花板和突破路径
- 复杂交互场景 (拖拽、批量操作、右键菜单) 的 schema 描述能力
- 看板/日历/甘特图等新页面类型的设计
- 大数据量下的性能优化 (虚拟滚动、懒加载)
- 插件自定义 UI 组件的可行性
### 专家组 5: 平台架构师
**焦点:** 插件平台的通用性和未来演进
- 插件间通信和协作机制
- 插件市场/分发的技术架构
- 插件依赖管理和版本兼容
- 多租户插件隔离的成本和安全性
### 专家组 6: 性能工程师
**焦点:** JSONB 动态表的性能天花板
- 百万级数据的查询优化策略
- 聚合查询的预计算/缓存
- 关系图谱大数据量下的前端渲染
- PostgreSQL JSONB 索引策略优化
---
## 四、实施计划
1. 退出计划模式后,立即调用 `/brainstorm` 技能,以本分析报告为基础材料
2. 对 6 个专家组视角逐一展开头脑风暴
3. 汇总所有专家建议,形成优先级排序的改进路线图
4. 输出最终的 CRM 插件改进计划

View File

@@ -0,0 +1,615 @@
# ERP 平台发散式探讨记录
> 日期: 2026-04-18 | 形式: 无主题发散式互动讨论
---
## 项目当前状态快照
**已完成:**
- Phase 1-6 核心平台 (core/auth/config/workflow/message/plugin)
- WASM 插件系统 (Wasmtime + WIT + 动态表 + 热更新)
- 2 个行业插件 (CRM 5实体 + 进销存 6实体)
- Q2-Q4 成熟度路线图 (安全/架构/测试/插件生态)
- 13 个 Rust crate, 37 个迁移, 15+ 前端页面
**进行中 (29 个未提交文件):**
- P0 平台能力升级 (实体关系增强/字段校验/前端去硬编码)
- 插件系统增强 (混合执行模型/聚合查询扩展/热更新原子回滚/Schema演进)
**代码中的 TODO:**
- Workflow 超时自动完成/升级逻辑
- Redis 缓存层 (data_service)
---
## 发散探讨方向
### 方向 A: 技术纵深 — 平台能力的下一个突破点
**插件系统能力边界在哪里?**
- 混合执行模型 (WASM + Host Query) 的安全边界如何界定?
- 插件能否拥有自己的定时任务?事件订阅后的异步处理链?
- WASM 组件之间的通信机制 — 插件 A 能否调用插件 B 的能力?
- 插件市场/分发机制 — 如何做到"一键安装"
**性能与规模化的隐藏挑战:**
- 动态表在海量数据下的查询性能 — 索引策略?
- 多租户隔离在大规模场景下的瓶颈 — schema-per-tenant 何时比 row-level 更优?
- WASM 执行的 Fuel 限制如何平衡安全与灵活性?
- 热更新期间的请求如何处理 — 连接排空?
### 方向 B: 业务纵深 — ERP 领域的深度探索
**CRM 插件的完整度缺口:**
- 商机/销售漏斗 — 从线索到成单的全链路
- 合同管理 — 模板、电子签章、履约跟踪
- 报价单 — 产品目录、价格策略、审批流
- 客户画像 — 标签体系、行为追踪、智能推荐
**下一个行业插件应该是什么?**
- 财务 (总账/应收/应付/固定资产)
- 采购 (供应商/询价/采购订单/入库)
- 制造 (BOM/工单/排产/质检)
- 人力 (员工/考勤/薪资/绩效)
- 电商 (商品/订单/物流/售后)
**跨模块业务流程:**
- 从销售订单 → 采购 → 入库 → 付款 的端到端流程
- 插件间的数据如何流转?订单确认触发采购申请?
- 工作流引擎如何编排跨插件流程?
### 方向 C: 体验纵深 — 前端与用户交互
**低代码/零代码的可能性:**
- 插件的前端页面能否完全由 schema 驱动生成?
- 可视化表单设计器 — 拖拽生成插件页面
- 自定义 Dashboard — 用户拼装自己的工作台
- 报表引擎 — 从数据到图表的可视化配置
**移动端/多端体验:**
- PWA 方案 — 离线能力 + 推送通知
- Tauri 桌面端何时启动?哪些场景需要桌面端?
- 小程序/企业微信集成 — 中国市场的刚需?
**AI 增强交互:**
- 自然语言查询 — "帮我查上个月销售额最高的 10 个客户"
- 智能推荐 — 基于操作习惯的快捷入口
- 数据洞察 — 自动发现异常趋势并提醒
- AI 辅助填单 — 自动补全/智能校验
### 方向 D: 商业纵深 — SaaS 化与商业化
**多租户高级能力:**
- 租户级别的功能开关 — 不同套餐解锁不同插件
- 计量计费 — 按用户数/存储/API调用量计费
- 租户数据导出/迁移 — 保障数据主权
- 白标/品牌定制 — 租户自定义 Logo/主题
**开放平台战略:**
- API Gateway + 开发者门户
- Webhook 系统 — 外部系统集成
- 第三方插件审核/上架流程
- 合作伙伴生态 — ISV 开发行业插件
### 方向 E: 团队与工程效率
**开发体验提升:**
- 插件开发脚手架 CLI — `erp-plugin create crm`
- 本地开发热重载 — 改 WASM 代码即时生效
- 插件调试工具 — 断点/日志/性能分析
- 一键生成插件 CRUD — 从 schema 到完整页面
**DevOps 与运维:**
- 蓝绿部署 / 金丝雀发布策略
- 数据库迁移的零停机方案
- 多环境管理 (dev/staging/prod)
- 监控告警体系 (APM + 日志聚合)
---
## 讨论记录
> 以下是互动讨论的要点,按时间顺序记录
### Round 1: "造一个财务插件来验证平台" — 立刻暴露了跨插件数据引用的缺失
**用户意图:** 希望通过搭建第二个行业插件(财务/应收),验证基座和插件系统,特别是与 CRM 插件的数据交互。
**已发现的系统缺陷 — 跨插件数据引用完全不支持:**
| 能力 | 现状 | 影响 |
|------|------|------|
| `ref_entity` 跨插件引用 | 仅限当前插件表空间 | 财务插件的 `customer_id` 无法声明指向 CRM 的 customer |
| Host API 跨插件查询 | `db-query` 无 plugin_id 参数 | WASM 插件无法查询其他插件数据 |
| PluginRelation 跨插件 | `entity` 字段无插件限定 | 无法声明跨插件的关联关系 |
| 前端 entity_select | 仅加载当前插件数据源 | 下拉框无法显示其他插件的实体列表 |
| 引用完整性校验 | 仅校验当前插件表空间 | 跨插件的外键约束无法生效 |
**进销存插件已有的"绕路":** `customer_id` 作为裸 UUID 存在,没有 `ref_entity` 声明 — 证明这是一个已知的痛点。
**唯一现有机制:** EventBus 事件广播(松耦合通知),但无法支持同步查询或声明式引用。
**财务插件与 CRM 的理想交互场景:**
```
CRM.customer ──引用──→ Finance.invoice.customer_id (外键 + 下拉选择)
CRM.opportunity ──引用──→ Finance.sales_order.opportunity_id
CRM.contact ──引用──→ Finance.quote.contact_id
```
**要实现这些,需要改造:**
1. `manifest.rs` — PluginField/PluginRelation 增加 `ref_plugin` 字段
2. `data_service.rs` — validate_ref_entities 支持跨插件表名解析
3. `plugin.wit` + `host.rs` — 新增跨插件查询 API
4. `dynamic_table.rs` — 表名解析支持目标 plugin_id
5. 前端 entity_select — 支持加载其他插件数据源
6. 权限模型 — 跨插件数据访问控制
### Round 2: 方案收敛 — 软引用 + 实体注册表 + 优雅降级
**决策记录:**
| 问题 | 决策 | 理由 |
|------|------|------|
| 引用模式 | **声明式** (plugin.toml) | 与现有 schema-driven 模式一致,插件作者零代码 |
| 依赖严格度 | **完全独立,无硬依赖** | SaaS 用户必须能自由组合/卸载插件 |
| 实体归属 | **插件自拥有,平台注册表发现** | 不改变现有模型,通过注册表实现运行时发现 |
| 悬空引用 | **软警告 + 后台对账** | 永不阻塞用户操作,对账工具引导修复 |
**架构设计:**
```
┌────────────────────────────────────────────────┐
│ Layer 3: Plugin (财务/采购/制造...) │
│ - optional_dependencies 声明 │
│ - ref_scope = "external" 跨插件引用字段 │
├────────────────────────────────────────────────┤
│ Layer 2: Entity Registry (平台实体注册表) │
│ - 插件安装时注册实体、卸载时标记 inactive │
│ - 查询时动态发现源插件 │
│ - 悬空引用检测 + 对账报告 │
├────────────────────────────────────────────────┤
│ Layer 1: Plugin System (现有基础设施) │
│ - 动态表、Host API、EventBus 不变 │
│ - 新增 Entity Registry 接入点 │
└────────────────────────────────────────────────┘
```
**plugin.toml 声明示例:**
```toml
[dependencies.crm]
optional = true
description = "客户管理 — 自动关联客户数据"
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
ref_entity = "customer"
ref_scope = "external"
ref_display_field = "name"
ref_fallback_label = "外部客户"
```
**运行时行为:**
| 源插件状态 | 写入 | 读取 | 展示 |
|-----------|------|------|------|
| 已安装 | 强校验 | JOIN 富化 | ✅ 绿色链接 "张三" |
| 未安装 | 无校验 | 原始 UUID | ⬜ 灰色 "外部客户" |
| 刚重新启用 | 新写入强校验 | 后台对账 | ⚠️ 黄色警告 (悬空) |
**悬空引用处理 (CRM 重新启用时):**
1. 后台扫描所有 `ref_scope=external` 的字段
2. 生成引用对账报告(有效/悬空分类)
3. 前端提示用户逐条处理(映射/清空/忽略)
4. 永不硬阻塞用户操作
**需改造的 6 个点:**
1. `manifest.rs` — 新增 `ref_scope`, `ref_display_field`, `ref_fallback_label`, `dependencies`
2. `entity_registry` (新模块) — 实体注册/发现/inactive 标记
3. `data_service.rs` — validate_ref_entities 支持运行时发现
4. `host.rs` + `plugin.wit` — 新增 resolve-ref-entity API
5. 前端 `entity_select` — 检测注册表,有源插件加载下拉,无则降级
6. 对账工具 — 后台扫描 + 前端对账 UI
### Round 3: 插件生态与商业化 — 技术优先路径
**用户选择:** 技术优先 → 市场,先做好平台能力再考虑商业模式。
**发现的三大技术缺口:**
1. **插件质量保障** — 安全扫描、性能基准、兼容性检测、运行时监控全部缺失
2. **插件配置与数据管理** — 导入导出、打印模板、配置 UI、自定义视图全部缺失
3. **插件市场/商店** — 浏览、发现、一键安装、评分全部缺失
**决策: 这些能力应该是平台级通用服务,不是插件各自实现。**
新增架构层:
```
插件 → Plugin Platform Services → Plugin System → ERP Core
导入导出 / 打印 / 配置 / 视图 / 通知 / 编号
```
**平台 P1 通用服务清单:**
| 服务 | 接入方式 | 财务插件示例 |
|------|---------|-------------|
| 数据导入导出 | plugin.toml 声明 importable/exportable | 导入客户清单、导出发票明细 |
| 打印模板 | 模板文件 + schema 映射 | 发票 PDF、收款凭证 |
| 插件配置 UI | plugin.toml 声明 settings | 税率表、付款条件、发票前缀 |
| 自定义视图 | 用户保存列/筛选配置 | 财务看不同列、销售看不同列 |
| 通知规则 | 插件定义触发事件 | 发票逾期 → 通知负责人 |
| 编号规则 | 复用 erp-config 的编号服务 | INV-2026-0001 |
### Round 4: 收敛 — 全部整合为一份设计规格
用户确认将所有讨论成果写入一份"插件平台演进设计规格"文档。
---
## 设计规格: ERP 插件平台演进路线图
> 基于 2026-04-18 发散式探讨的成果,涵盖跨插件引用、平台通用服务、质量保障、插件市场四个维度。
### 1. 背景与动机
ERP 平台已完成 Phase 1-6 核心开发和 Q2-Q4 成熟度路线图。当前有两个行业插件CRM + 进销存)运行在 WASM 插件系统上。但通过分析发现:
- **跨插件数据引用完全不支持** — 进销存的 `customer_id` 只能存裸 UUID
- **插件无通用业务能力** — 导入导出/打印/配置/视图每个插件都要自己实现
- **无质量保障机制** — 第三方插件的安全性和性能无法保证
- **无发现和分发渠道** — 用户无法自助发现和安装插件
目标:通过搭建财务/应收插件来验证和推动这些平台能力的实现。
### 2. 跨插件数据引用系统
#### 2.1 设计原则
- **插件完全独立** — 任何插件可独立安装/卸载,不受其他插件影响
- **声明式配置** — 跨插件引用通过 plugin.toml 声明,插件作者零代码
- **优雅降级** — 源插件不存在时功能降级,不阻塞用户操作
- **软警告** — 外部引用问题永远是警告,不是错误
#### 2.2 实体注册表 (Entity Registry)
**数据结构:**
```
entity_registry:
- entity_name: string # 实体名 (如 "customer")
- plugin_id: string # 注册该实体的插件 ID
- display_fields: string[] # 用于下拉显示的字段列表
- search_fields: string[] # 用于搜索的字段列表
- status: active | inactive # 插件卸载时标记 inactive
- registered_at: timestamp
- tenant_id: uuid # 多租户隔离
```
**生命周期:**
- 插件安装 → 注册所有 entities 到 registry
- 插件启用 → status = active
- 插件禁用 → status = inactive数据保留
- 插件卸载 → status = inactive + 标记为 orphaned
#### 2.3 plugin.toml 扩展
```toml
# 可选依赖声明
[dependencies.crm]
optional = true
description = "客户管理 — 自动关联客户数据,未安装时客户字段为手动输入"
[dependencies.inventory]
optional = true
description = "进销存 — 自动关联商品数据"
# 跨插件引用字段
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
display_name = "客户"
ref_entity = "customer" # 目标实体名
ref_scope = "external" # "internal" (默认) | "external"
ref_display_field = "name" # 下拉框显示字段
ref_search_fields = ["name", "phone"] # 搜索字段
ref_fallback_label = "外部客户" # 降级时显示文本
```
#### 2.4 运行时行为
**写入时校验:**
```
IF ref_scope == "external":
registry = EntityRegistry.find("customer")
IF registry.status == "active":
强校验: customer_id 必须存在于 registry.plugin_id 的对应表中
ELSE:
无校验: 接受任意 UUID
```
**读取时富化:**
```
IF ref_scope == "external" AND registry.status == "active":
JOIN plugin_{registry.plugin_id}_{ref_entity} 获取 display_field
前端显示: "张三 (CRM)" (绿色可点击链接)
ELIF ref_scope == "external" AND registry.status == "inactive":
前端显示: "外部客户 ({uuid})" (灰色)
```
**悬空引用处理:**
```
ON plugin.activate:
1. 后台扫描所有 ref_scope="external" 且指向本插件实体的字段
2. 验证每个 UUID 是否存在于本插件表中
3. 生成对账报告: { valid: N, dangling: M, details: [...] }
4. 前端展示对账结果,用户逐条处理
```
#### 2.5 需要改造的文件
| 文件 | 改动 | 复杂度 |
|------|------|--------|
| `crates/erp-plugin/src/manifest.rs` | 新增 `ref_scope`, `ref_display_field`, `ref_search_fields`, `ref_fallback_label`; 新增 `DependenciesSection` | 低 |
| `crates/erp-plugin/src/entity_registry.rs` (新) | 实体注册/发现/inactive 标记/对账 | 中 |
| `crates/erp-plugin/src/data_service.rs` | `validate_ref_entities` 支持运行时发现外部引用 | 中 |
| `crates/erp-plugin/src/host.rs` | 新增 `resolve_ref_entity` Host API | 中 |
| `crates/erp-plugin/wit/plugin.wit` | 新增 `resolve-ref-entity` 接口 | 低 |
| `crates/erp-plugin/src/service.rs` | 插件安装/卸载时维护 Entity Registry | 中 |
| `apps/web/src/` 前端 | entity_select 组件支持跨插件数据源 + 降级显示 + 对账 UI | 高 |
### 3. 插件平台通用服务层 (P1)
#### 3.1 数据导入导出服务
**设计思路:** 插件在 plugin.toml 中声明哪些实体支持导入导出,平台提供统一的导入导出 UI 和引擎。
```toml
# plugin.toml 中的声明
[[schema.entities]]
name = "invoice"
display_name = "发票"
importable = true
exportable = true
import_template = "invoice_import_template.xlsx" # 可选: 自定义导入模板
[[schema.entities]]
name = "payment"
display_name = "收款"
importable = true
exportable = true
```
**平台能力:**
- 自动生成导入模板(基于 schema entities fields
- Excel/CSV 解析 + schema 字段校验
- 批量写入(支持事务 + 错误行级报告)
- 导出为 Excel/CSV支持筛选条件
- 导入历史记录 + 回滚
**实现位置:** 新增 `crates/erp-plugin/src/import_export.rs`,前端新增 `ImportExportModal` 通用组件。
#### 3.2 打印模板引擎
**设计思路:** 平台提供 HTML → PDF 的模板渲染能力,插件定义模板和字段映射。
```toml
# plugin.toml 中的声明
[[templates]]
name = "invoice_pdf"
display_name = "发票"
entity = "invoice"
format = "pdf"
template_file = "templates/invoice.html" # HTML 模板
```
**平台能力:**
- HTML 模板渲染 → PDF 下载
- 模板变量替换(基于实体字段)
- 租户级模板自定义(覆盖默认模板)
- 打印预览
**实现位置:** 后端使用 `wkhtmltopdf``headless-chrome` 渲染,前端新增 `PrintPreviewModal` 组件。
#### 3.3 插件配置 UI
**设计思路:** 插件在 plugin.toml 中声明配置项,平台自动生成配置页面。
```toml
# plugin.toml 中的声明
[settings]
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率"
field_type = "number"
default_value = 0.13
[[settings.fields]]
name = "invoice_prefix"
display_name = "发票前缀"
field_type = "text"
default_value = "INV"
[[settings.fields]]
name = "payment_terms"
display_name = "默认付款条件"
field_type = "select"
options = ["net_15", "net_30", "net_60", "cod"]
default_value = "net_30"
```
**平台能力:**
- 根据 settings 声明自动生成配置表单
- 配置数据存储在 `plugin_settings`tenant_id + plugin_id + key/value
- 配置变更时通知插件(通过事件)
- 支持配置权限控制(仅管理员可改)
#### 3.4 自定义视图
**设计思路:** 用户可以保存列表页的列配置和筛选条件。
```
user_views:
- id: uuid
- user_id: uuid
- plugin_id: string
- entity_name: string
- view_name: string
- columns: string[] # 显示的列
- filters: json # 筛选条件
- sort: json # 排序条件
- is_default: boolean
```
**平台能力:**
- 列表页支持列拖拽排序、显示/隐藏
- 筛选条件保存/加载
- 每个用户可以有多个视图
- 支持共享视图给同角色用户
#### 3.5 通知规则
**设计思路:** 插件在 plugin.toml 中声明可触发的事件,平台提供通知规则配置 UI。
```toml
# plugin.toml 中的声明
[[trigger_events]]
name = "invoice.overdue"
display_name = "发票逾期"
description = "发票超过付款期限未收款"
[[trigger_events]]
name = "payment.received"
display_name = "收款确认"
```
**平台能力:**
- 规则引擎: WHEN event THEN notify [user/role/department]
- 复用 erp-message 的通知渠道
- 租户级规则配置
- 通知模板自定义
#### 3.6 编号规则 (已有基础扩展)
**设计思路:** 复用 erp-config 的编号规则服务,扩展为插件可接入。
```toml
# plugin.toml 中的声明
[[numbering]]
entity = "invoice"
prefix = "INV"
format = "{PREFIX}-{YEAR}-{SEQ:4}"
reset_rule = "yearly" # daily/monthly/yearly/never
```
### 4. 插件质量保障
#### 4.1 上传时校验
```
插件上传 → Schema 校验 → WASM 二进制验证 → 安全扫描 → 性能基准 → 发布/拒绝
```
| 阶段 | 校验内容 | 现状 |
|------|---------|------|
| Schema 校验 | plugin.toml 格式、字段类型、权限码一致性 | ✅ 已有部分 |
| WASM 验证 | 二进制格式、WIT 兼容性、导出函数检查 | ✅ 已有 |
| 安全扫描 | 动态表 SQL 注入风险、Fuel 耗尽、内存泄漏 | ❌ 缺失 |
| 性能基准 | 标准 CRUD 操作在 N 条数据下的响应时间 | ❌ 缺失 |
| 兼容性 | 平台版本匹配、依赖插件版本兼容 | ❌ 缺失 |
#### 4.2 运行时监控
```
plugin_runtime_metrics:
- plugin_id: string
- error_rate: float # 24h 错误率
- avg_response_ms: float # 平均响应时间
- fuel_consumption: float # 平均 Fuel 消耗
- memory_peak_mb: float # 内存峰值
- active_instances: int # 活跃实例数
```
**告警规则:**
- 错误率 > 5% → 警告
- 平均响应 > 2s → 警告
- Fuel 消耗异常 → 警告
- 内存持续增长 → 疑似泄漏
### 5. 插件市场/商店
#### 5.1 功能范围
| 功能 | 说明 |
|------|------|
| 插件目录 | 按行业/功能分类浏览 |
| 搜索 | 按名称/标签/行业搜索 |
| 详情页 | 截图、演示、功能描述、权限说明 |
| 一键安装 | 上传 → 自动安装 → 配置 → 启用 |
| 评分/评论 | 用户评分和使用反馈 |
| 版本管理 | 版本列表、更新日志、回滚 |
| 依赖提示 | 安装时提示可选依赖("推荐配合 CRM 使用" |
#### 5.2 技术实现
- 后端: 新增 `plugin_store` 表 + API
- 前端: 新增 `PluginStore` 页面
- 管理端: 管理员审核/上架/下架
### 6. 验证计划 — 财务/应收插件
#### 6.1 实体设计
| 实体 | 字段概要 | 跨插件引用 |
|------|---------|-----------|
| invoice (发票) | 编号/客户/金额/税额/状态/到期日 | customer_id → CRM.customer |
| invoice_line (发票行) | 发票/商品/数量/单价/税额 | product_id → Inventory.product |
| payment (收款) | 发票/金额/方式/日期/状态 | invoice_id → 本插件内部 |
| quote (报价单) | 编号/客户/有效期/状态 | customer_id → CRM.customer |
| quote_line (报价行) | 报价单/商品/数量/单价 | product_id → Inventory.product |
#### 6.2 验证矩阵
| 能力 | 验证方式 | 预期结果 |
|------|---------|---------|
| 跨插件引用 (CRM 安装) | 创建发票时选择客户 | entity_select 下拉显示 CRM 客户列表 |
| 跨插件引用 (CRM 卸载) | 创建发票时输入客户 | 降级为文本输入,不阻塞 |
| 悬空引用对账 | CRM 卸载→创建发票→重新安装 CRM | 对账报告显示悬空引用,用户可修复 |
| 数据导入 | 导入 Excel 客户清单 | 解析+校验+批量写入 |
| 数据导出 | 导出发票列表为 Excel | 筛选+下载 |
| 打印模板 | 打印发票 PDF | HTML→PDF 渲染 |
| 插件配置 | 设置税率/发票前缀 | 自动生成的配置页面 |
| 编号规则 | 创建发票自动编号 | INV-2026-0001 |
| 通知规则 | 发票逾期通知 | 规则引擎触发通知 |
| 独立安装 | 不安装 CRM 单独安装财务 | 所有功能正常,客户字段降级 |
### 7. 实施优先级
```
P0 (已完成/进行中): P0 平台能力升级 (实体关系增强/字段校验/前端去硬编码)
插件系统增强 (混合执行模型/聚合查询/热更新回滚/Schema演进)
P1 (跨插件引用): Entity Registry + ref_scope 扩展 + 前端 entity_select 改造
这是所有后续能力的基础
P2 (平台通用服务): 数据导入导出 → 插件配置 UI → 编号规则扩展 → 通知规则
按业务迫切程度排序
P3 (质量保障): 上传时安全扫描 → 性能基准 → 运行时监控
逐步建立信任体系
P4 (插件市场): 插件目录 → 一键安装 → 版本管理 → 评分评论
商业化的最后一块拼图
验证: 财务/应收插件贯穿 P1-P2每完成一个 P 就用财务插件验证
```
### 8. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| Entity Registry 查询性能 | 每次数据操作都要查注册表 | 内存缓存 + DashMap注册表数据量极小 |
| 悬空引用数据量过大 | 对账扫描耗时长 | 异步后台任务 + 分批处理 + 进度条 |
| Excel 导入内存占用 | 大文件解析 OOM | 流式解析 + 批量提交 + 文件大小限制 |
| 打印模板安全 | 模板注入攻击 | 沙箱渲染 + 变量白名单 |
| 插件市场审核成本 | 人工审核效率低 | 自动化扫描 + 人工抽查 + 社区举报 |

View File

@@ -0,0 +1,488 @@
# 插件系统增强设计规格
## Context
插件系统是 ERP 平台的核心差异化能力当前声明式层面manifest schema、动态表、前端页面已达 90% 成熟度。但 WASM 逻辑层存在根本性限制:
1. **插件无法自主查询数据**`db_query` 的 filter/pagination 参数被忽略,只能使用预填充结果
2. **无读后写一致性** — 延迟刷新模型导致插件在一次调用中无法读取自己刚写入的数据
3. **聚合只有 COUNT** — 缺少 SUM/AVG/MAX/MIN无法支撑财务、统计类场景
4. **热更新无原子回滚** — 旧版本先卸载再加载新版本,中间失败无保障
5. **Schema 变更只支持新增实体** — 不支持已有实体的字段演进
这些限制使插件系统只能支撑"数据管理+展示"型轻量场景CRM、简单进销存无法支撑需要复杂业务逻辑的行业财务、制造、电商
本次增强的目标:**让插件逻辑层从 40% 提升到 80%+,使系统能真正承载不同行业的定制化需求。**
---
## 改动 1混合执行模型解决查询和读后写一致性
### 问题
`host.rs:99-109``db_query` 忽略 `_filter``_pagination` 参数,只从 `query_results` 预填充缓存取数据。插件无法自主构造查询。
### 方案:读操作走实时 SQL + 写操作保持延迟批量 + 读前自动 flush
**核心流程变更:**
```
当前:
WASM 调用 db_insert() → 入队 pending_ops
WASM 调用 db_query() → 从预填充缓存读(忽略 filter/pagination
WASM 结束 → flush 全部 pending_ops
改为:
WASM 调用 db_insert() → 入队 pending_ops
WASM 调用 db_query() → 先 flush pending_ops → 执行真实 SQL 查询 → 返回结果
WASM 结束 → flush 剩余 pending_ops
```
### 改动文件
#### 1. `crates/erp-plugin/src/host.rs`
**HostState 新增字段:**
```rust
pub struct HostState {
// ... 现有字段保留 ...
// 新增:用于实时查询的数据服务引用和数据库连接
pub(crate) db: Option<DatabaseConnection>,
pub(crate) data_service_ready: bool,
}
```
**db_query 实现变更:**
```rust
fn db_query(&mut self, entity: String, filter: Vec<u8>, pagination: Vec<u8>)
-> Result<Vec<u8>, String>
{
// 如果没有数据库连接(向后兼容预填充模式),走旧路径
if self.db.is_none() {
return self.query_results
.get(&entity)
.cloned()
.ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity));
}
// 解析 filter 和 pagination 参数
let filter_val: Option<serde_json::Value> = if filter.is_empty() {
None
} else {
serde_json::from_slice(&filter).ok()
};
let pagination_val: Option<serde_json::Value> = if pagination.is_empty() {
None
} else {
serde_json::from_slice(&pagination).ok()
};
// 先同步 flush pending writes确保读后写一致性
// 注意:在 WASM 的 spawn_blocking 上下文中,需要同步执行
// 方案:将 pending_ops 暂存到临时变量,由调用方在 execute_wasm 中处理
// 使用 pre_query_ops 标记,让 engine 在 execute_wasm 中间阶段 flush
self.pre_query_ops = std::mem::take(&mut self.pending_ops);
self.pending_query = Some(PendingQuery { entity, filter_val, pagination_val });
// 返回占位符 — 真正的查询在 execute_wasm 的两阶段执行中完成
Ok(serde_json::to_vec(&serde_json::json!({"status": "query_pending"})).unwrap_or_default())
}
```
**实际实现策略 — 采用回调模式:**
由于 `db_query``spawn_blocking` 内执行,不能直接 await 异步数据库操作。采用两阶段执行:
1. WASM 执行期间:`db_query` 收集查询参数,设置 `needs_flush_and_query = true`
2. `execute_wasm``spawn_blocking` 结束后:检查标志,如果需要查询则:
- flush pending_ops
- 执行查询
- 用查询结果重新调用 WASM继续执行后续逻辑
**更好的方案 — 分段执行:**
`execute_wasm` 改为分段执行模型:
```rust
async fn execute_wasm(&self, ...) -> PluginResult<R> {
// 阶段 1执行 WASM遇到 db_query 时暂停
let (result, pending_ops, pending_queries) = tokio::task::spawn_blocking(move || {
// WASM 执行中遇到 db_query 时,收集查询参数并设置标志
// 标志在 HostState 中self.needs_query = true, self.query_params = ...
// WASM 继续执行db_query 返回空结果集作为占位)
// ...
}).await?;
// 中间阶段flush writes + execute queries
Self::flush_ops(&self.db, plugin_id, pending_ops, ...).await?;
let query_results = Self::execute_queries(&self.db, plugin_id, pending_queries, ...).await?;
// 阶段 2如果有待处理的查询重新执行 WASM或继续后续逻辑
// ...
}
```
**最终推荐方案 — 简化版:**
实际上最简单的做法是:**让 db_query 同步执行真实查询**。在 `spawn_blocking` 中使用 `tokio::runtime::Handle` 来在阻塞线程中执行异步代码。
```rust
fn db_query(&mut self, entity: String, filter: Vec<u8>, pagination: Vec<u8>)
-> Result<Vec<u8>, String>
{
let db = self.db.as_ref().ok_or("数据库连接不可用")?;
// 先 flush pending writes通过 tokio handle 在阻塞上下文中执行异步)
let rt = tokio::runtime::Handle::current();
let ops = std::mem::take(&mut self.pending_ops);
if !ops.is_empty() {
rt.block_on(Self::flush_ops_static(db, &self.plugin_id, ops,
self.tenant_id, self.user_id, &self.event_bus))
.map_err(|e| format!("flush 失败: {}", e))?;
}
// 解析 filter
let filter_val: Option<serde_json::Value> = if filter.is_empty() {
None
} else {
serde_json::from_slice(&filter).ok()
};
// 构建并执行查询
let table_name = DynamicTableManager::table_name(&self.plugin_id, &entity);
let (sql, values) = DynamicTableManager::build_query_sql(
&table_name, self.tenant_id, filter_val, pagination_val
).map_err(|e| e.to_string())?;
let rows: Vec<serde_json::Value> = rt.block_on(async {
// 执行查询
}).map_err(|e| e.to_string())?;
serde_json::to_vec(&rows).map_err(|e| e.to_string())
}
```
**改动影响:**
- `HostState` 增加 `db: Option<DatabaseConnection>``event_bus: Option<EventBus>` 字段
- `execute_wasm` 创建 HostState 时传入 db 和 event_bus
- `db_query` 从忽略参数改为实时查询
- `PluginEngine::new` 已持有 db 和 event_bus无需新增依赖
#### 2. `crates/erp-plugin/src/dynamic_table.rs`
新增 `build_query_sql` 方法,复用现有 `data_service.rs` 的查询构建逻辑:
```rust
pub fn build_query_sql(
table_name: &str,
tenant_id: Uuid,
filter: Option<serde_json::Value>,
pagination: Option<serde_json::Value>,
) -> Result<(String, Vec<sea_orm::Value>)>
```
### 向后兼容
- `HostState::new()` 不传 db → `db = None` → 走旧的预填充路径
- `execute_wasm()` 传 db → 走新的实时查询路径
- 现有 WASM 插件无需修改(旧路径仍然可用)
---
## 改动 2扩展聚合查询
### 问题
`data_service.rs:655``aggregate` 方法只支持 `GROUP BY + COUNT(*)`,返回 `Vec<(String, i64)>`
### 方案
扩展聚合函数支持 SUM/AVG/MAX/MIN。
#### 改动文件
**1. `crates/erp-plugin/src/data_service.rs`**
新增多聚合函数方法:
```rust
pub struct AggregateResult {
pub key: String,
pub metrics: HashMap<String, f64>, // "count" -> 10, "total_amount" -> 5000.0
}
pub async fn aggregate_multi(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
db: &DatabaseConnection,
group_by_field: &str,
aggregations: &[AggregateDef], // [{field: "amount", func: "sum"}, ...]
filter: Option<serde_json::Value>,
scope: Option<DataScopeParams>,
) -> AppResult<Vec<AggregateResult>>
pub struct AggregateDef {
pub field: String,
pub func: AggregateFunc,
}
pub enum AggregateFunc {
Count,
Sum,
Avg,
Min,
Max,
}
```
SQL 构建示例:
```sql
SELECT _f_status as key,
COUNT(*) as count,
COALESCE(SUM(_f_amount), 0) as sum_amount,
COALESCE(AVG(_f_price), 0) as avg_price
FROM plugin_erp_crm__order
WHERE tenant_id = $1 AND deleted_at IS NULL
GROUP BY _f_status
```
**2. `crates/erp-plugin/src/dynamic_table.rs`**
新增 `build_aggregate_multi_sql` 方法,构建多聚合 SQL。
**3. `crates/erp-plugin/src/data_handler.rs`**
扩展聚合 API 端点,接受 `aggregations` 参数:
```json
POST /api/v1/plugins/{pluginId}/data/{entityName}/aggregate
{
"group_by": "status",
"aggregations": [
{"field": "amount", "func": "sum"},
{"field": "price", "func": "avg"}
],
"filter": {"status": "active"}
}
```
**4. 前端 Dashboard Widget 适配**
`PluginDashboardPage.tsx` 中的 `stat_card` 和图表 widget 需要适配新的多聚合返回格式。
---
## 改动 3热更新原子回滚
### 问题
`service.rs:578-585` — 升级时先 `unload(old)``load(new)`,如果 `load` 失败,旧版本已不在内存中。
### 方案:先加载新版本,成功后原子替换
#### 改动文件
**`crates/erp-plugin/src/service.rs`** — `upgrade` 方法:
```rust
// 当前(有风险):
engine.unload(plugin_manifest_id).await.ok(); // 旧版本已卸载
engine.load(plugin_manifest_id, &new_wasm, manifest) // 如果这里失败 → 无回滚
.await?;
// 改为(安全):
// 1. 先加载新版本(用临时 key
let temp_id = format!("{}__upgrade_{}", plugin_manifest_id, Uuid::now_v7());
engine.load(&temp_id, &new_wasm, new_manifest.clone()).await
.map_err(|e| {
tracing::error!(error = %e, "新版本 WASM 加载失败,旧版本仍在运行");
e
})?;
// 2. 卸载旧版本
engine.unload(plugin_manifest_id).await.ok();
// 3. 将新版本从临时 key 改为正式 key
engine.rename_plugin(&temp_id, plugin_manifest_id).await?;
// 4. 更新数据库记录
```
**`crates/erp-plugin/src/engine.rs`** — 新增 `rename_plugin` 方法:
```rust
pub async fn rename_plugin(&self, old_id: &str, new_id: &str) -> PluginResult<()> {
let loaded = self.plugins.remove(old_id)
.ok_or_else(|| PluginError::NotFound(old_id.to_string()))?;
let mut loaded = Arc::try_unwrap(loaded.1)
.map_err(|_| PluginError::ExecutionError("插件仍被引用".to_string()))?;
loaded.id = new_id.to_string();
self.plugins.insert(new_id.to_string(), Arc::new(loaded));
Ok(())
}
```
**改进后的安全保证:**
- 新版本加载失败 → 旧版本仍在运行,零停机
- 数据库记录只在 WASM 替换成功后才更新
- 事务性:要么完全切换到新版本,要么保持旧版本
---
## 改动 4Schema 演进ALTER TABLE 支持)
### 问题
`service.rs:562-575` — 升级时只处理新增实体CREATE TABLE不处理已有实体的字段变更。
### 方案:利用 JSONB 特性实现轻量级 Schema 演进
由于核心数据在 JSONB 的 `data` 列中,大部分字段变更不需要 DDL
- **新增字段**JSONB 天然支持,只需更新 manifest
- **新增 filterable/sortable 字段**:需要 ALTER TABLE ADD Generated Column + 索引
- **删除字段**JSONB 中多余字段不影响Generated Column 可保留(无害)
- **重命名字段**:添加新 Generated Column旧的保留
- **修改字段类型**Generated Column 需要 DROP + ADDJSONB 数据不需要改)
#### 改动文件
**`crates/erp-plugin/src/service.rs`** — `upgrade` 方法增加 schema diff 逻辑:
```rust
// 对比 schema 变更
if let Some(new_schema) = &new_manifest.schema {
let old_schema = old_manifest.schema.as_ref();
for new_entity in &new_schema.entities {
let old_entity = old_schema
.and_then(|s| s.entities.iter().find(|e| e.name == new_entity.name));
match old_entity {
None => {
// 全新实体 — CREATE TABLE
DynamicTableManager::create_table(db, plugin_manifest_id, new_entity).await?;
}
Some(old) => {
// 已有实体 — diff 字段
let diff = diff_entity_fields(old, new_entity);
if !diff.new_filterable.is_empty() || !diff.new_sortable.is_empty() {
DynamicTableManager::alter_add_generated_columns(
db, plugin_manifest_id, new_entity, &diff
).await?;
}
}
}
}
}
```
**`crates/erp-plugin/src/dynamic_table.rs`** — 新增:
```rust
pub struct FieldDiff {
pub new_filterable: Vec<PluginField>, // 新增的需要 Generated Column 的字段
pub new_sortable: Vec<PluginField>,
pub new_searchable: Vec<PluginField>, // 新增的需要 pg_trgm 索引的字段
}
pub fn diff_entity_fields(old: &PluginEntity, new: &PluginEntity) -> FieldDiff
pub async fn alter_add_generated_columns(
db: &DatabaseConnection,
plugin_id: &str,
entity: &PluginEntity,
diff: &FieldDiff,
) -> PluginResult<()>
```
ALTER TABLE 示例:
```sql
-- 新增 filterable 字段
ALTER TABLE plugin_erp_crm__customer
ADD COLUMN IF NOT EXISTS _f_source TEXT GENERATED ALWAYS AS (data->>'source') STORED;
-- 新增索引
CREATE INDEX IF NOT EXISTS idx_plugin_erp_crm__customer__f_source
ON plugin_erp_crm__customer (_f_source) WHERE deleted_at IS NULL;
-- 新增 searchable 字段的 pg_trgm 索引
CREATE INDEX IF NOT EXISTS idx_plugin_erp_crm__customer__f_source_trgm
ON plugin_erp_crm__customer USING gin (_f_source gin_trgm_ops)
WHERE deleted_at IS NULL;
```
---
## 实施顺序
| 阶段 | 改动 | 复杂度 | 影响范围 |
|------|------|--------|---------|
| 1 | 热更新原子回滚 | 低 | engine.rs + service.rs |
| 2 | Schema 演进ALTER TABLE | 中低 | service.rs + dynamic_table.rs |
| 3 | 扩展聚合查询 | 中 | data_service.rs + data_handler.rs + dynamic_table.rs |
| 4 | 混合执行模型(查询能力) | 高 | host.rs + engine.rs + dynamic_table.rs |
建议按复杂度从低到高实施,每个阶段独立可验证。
---
## 验证方案
### 阶段 1热更新回滚
1. 准备两个版本的 CRM 插件 WASMv1.0.0 和 v2.0.0
2. 上传 v2.0.0 但故意让 WASM 二进制损坏
3. 验证:旧版本 v1.0.0 仍在正常运行
4. 上传正确的 v2.0.0
5. 验证:成功切换到 v2.0.0
### 阶段 2Schema 演进
1. 创建 CRM 插件 v1.0.0(含 customer 实体3 个字段)
2. 升级到 v1.1.0customer 增加 2 个 filterable 字段 + 1 个新实体 contact
3. 验证:新字段可以过滤/排序,旧数据不受影响
4. 在已有数据上验证新 Generated Column 的值正确填充
### 阶段 3聚合查询
1. 创建测试数据(不同状态的订单,含 amount 字段)
2. 调用聚合 APIgroup_by=status, aggregations=[sum(amount), avg(amount)]
3. 验证返回结果正确
4. 前端 Dashboard stat_card 展示正确的聚合数据
### 阶段 4混合执行模型
1. 在插件 WASM 中调用 db_insert 后立即 db_query
2. 验证能读取到刚插入的数据(读后写一致性)
3. 验证带 filter 参数的 db_query 返回正确过滤结果
4. 验证旧插件(使用预填充模式)仍能正常工作
5. 压力测试:多次连续 db_query 不超过 Fuel 限制
---
## 关键文件清单
| 文件 | 改动类型 |
|------|---------|
| `crates/erp-plugin/src/host.rs` | 重构 db_query + 新增 db/事件总线字段 |
| `crates/erp-plugin/src/engine.rs` | 调整 execute_wasm + 新增 rename_plugin |
| `crates/erp-plugin/src/service.rs` | 升级流程回滚安全 + schema diff |
| `crates/erp-plugin/src/dynamic_table.rs` | 新增 build_query_sql + alter_add_generated_columns + diff_entity_fields |
| `crates/erp-plugin/src/data_service.rs` | 新增 aggregate_multi + AggregateDef |
| `crates/erp-plugin/src/data_handler.rs` | 扩展聚合 API |
| `apps/web/src/pages/PluginDashboardPage.tsx` | 适配多聚合返回格式 |
### 可复用的现有函数
- `DynamicTableManager::build_query_sql` — 可复用 `data_service.rs` 中的查询构建逻辑
- `DynamicTableManager::build_insert_sql` — flush 时已有,无需改动
- `sanitize_identifier` — 已有,用于新字段名的安全检查
- `flush_ops` — 已有事务性 flush 逻辑,混合模型中复用

View File

@@ -0,0 +1,357 @@
# WASM 插件系统设计 — 可行性分析与原型验证计划
> 基于 `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` 的全面审查
---
## 1. 总体评估
**结论:技术可行,但需要对设计做重要调整。**
设计方案的核心思路WASM 沙箱 + 宿主代理 API + 配置驱动 UI是正确的架构方向。Wasmtime v43.x + Component Model + WASI 0.2/0.3 已达到生产就绪状态。但设计中有 **7 个关键问题****5 个改进点** 需要在实施前解决。
**可行性评分:**
| 维度 | 评分 | 说明 |
|------|------|------|
| 技术可行性 | 8/10 | Wasmtime 成熟,核心 API 可用;动态表和事务支持需额外封装 |
| 架构兼容性 | 6/10 | 与现有 FromRef 状态模式、静态路由模式存在结构性冲突 |
| 安全性 | 9/10 | WASM 沙箱 + 权限模型 + 租户隔离设计扎实 |
| 前端可行性 | 7/10 | PluginCRUDPage 概念正确但实现细节不足 |
| 实施复杂度 | 高 | 估计 3 个 Phase、6-8 周工作量 |
---
## 2. 与现有代码的兼容性分析
### 2.1 现有架构快照
| 组件 | 现状 | 设计目标 | 差距 |
|------|------|----------|------|
| ErpModule trait | 7 个方法name/version/dependencies/register_event_handlers/on_tenant_created/on_tenant_deleted/as_any | 15+ 方法(新增 id/module_type/on_startup/on_shutdown/health_check/public_routes/protected_routes/migrations/config_schema | **重大差距** |
| ModuleRegistry | `Arc<Vec<Arc<dyn ErpModule>>>`Builder 模式注册,无路由收集 | 需要 build_routes()、topological_sort()、load_wasm_plugins()、health_check_all() | **重大差距** |
| EventBus | `tokio::broadcast`,仅有 subscribe()(全量订阅) | 需要 subscribe_filtered() + unsubscribe() | **中等差距** |
| 路由 | main.rs 手动 merge 静态方法 | registry.build_routes() 自动收集 | **中等差距** |
| 状态注入 | FromRef 模式(编译时桥接 AppState → 各模块 State | WASM 插件需要运行时状态注入 | **结构性冲突** |
| 前端菜单 | MainLayout.tsx 硬编码 3 组菜单 | 从 PluginStore 动态生成 | **中等差距** |
| 前端路由 | App.tsx 静态定义React.lazy 懒加载 | DynamicRouter 根据插件配置动态生成 | **中等差距** |
### 2.2 关键差距详解
**差距 1ErpModule trait 路由方法缺失**
当前路由不是 trait 的一部分,而是每个模块的关联函数:
```rust
// 当前(静态关联函数,非 trait 方法)
impl AuthModule {
pub fn public_routes<S: Clone + Send + Sync + 'static>() -> Router<S> { ... }
pub fn protected_routes<S: Clone + Send + Sync + 'static>() -> Router<S> { ... }
}
```
设计将路由提升为 trait 方法,但这引入了泛型参数问题:`Router<S>` 中的 `S``AppState` 类型,而 trait 不能有泛型方法(会导致 trait 不是 object-safe。需要设计新的路由注入机制。
**差距 2FromRef 状态模式与 WASM 插件的冲突**
当前每个模块有自己的 State 类型(`AuthState``ConfigState` 等),通过 `FromRef``AppState` 桥接。WASM 插件无法定义编译时的 `FromRef` 实现,需要运行时状态传递机制。
**差距 3EventBus 缺少类型化订阅**
`subscribe()` 返回 `broadcast::Receiver<DomainEvent>`,订阅者需要自行过滤。这会导致每个插件都收到所有事件,增加不必要的开销。设计中的 `subscribe_filtered()` 是必要的扩展。
---
## 3. 关键问题Critical Issues
### C1: 路由注入机制设计不完整
**问题:** 设计中的 `fn public_routes(&self) -> Option<Router>``fn protected_routes(&self) -> Option<Router>` 缺少泛型参数 `S`AppState。Axum 的 Router 依赖状态类型,而 trait object 不能携带泛型。
**影响:** 这是路由自动收集的基础。如果无法解决,整个 `registry.build_routes()` 的设计就不能实现。
**建议方案:**
```rust
// 方案:使用 Router<()>, 由 ModuleRegistry 在 build_routes() 时添加 .with_state()
pub trait ErpModule: Send + Sync {
fn protected_routes(&self) -> Option<Router<()>> { None }
fn public_routes(&self) -> Option<Router<()>> { None }
}
impl ModuleRegistry {
pub fn build_routes(&self, state: AppState) -> (Router, Router) {
let public = self.modules.iter()
.filter_map(|m| m.public_routes())
.fold(Router::new(), |acc, r| acc.merge(r))
.with_state(state.clone());
let protected = self.modules.iter()
.filter_map(|m| m.protected_routes())
.fold(Router::new(), |acc, r| acc.merge(r))
.with_state(state);
(public, protected)
}
}
```
### C2: 事务支持缺失
**问题:** Host API 每次 db 调用都是独立事务。但 ERP 业务经常需要多步骤原子操作。
**建议:** 增加声明式事务,插件提交一组操作,宿主在单事务中执行。
### C3: 插件间依赖未解决
**建议:** Phase 7 暂不实现,在 plugin.toml 中预留 `plugins = [...]` 字段。
### C4: 版本号严重过时
**建议:** 锁定 `wasmtime = "43"` + `wit-bindgen = "0.55"`
### C5: 动态表 Schema 迁移策略缺失
**建议:** 添加 schema_version + 迁移 SQL 机制。
### C6: PluginCRUDPage 实现细节不足
**建议:** 扩展关联数据、主从表、文件上传支持。
### C7: 错误传播机制不完整
**建议:** 定义结构化插件错误协议。
---
## 4. 替代方案比较
**结论WASM 方案优于 Lua 脚本和进程外 gRPC。** dylib 因安全性排除。
---
## 5. Wasmtime 原型验证计划
### 5.1 验证目标
验证 Wasmtime Component Model 与 ERP 插件系统核心需求的集成可行性:
| # | 验证项 | 关键问题 |
|---|--------|---------|
| V1 | WIT 接口定义 + bindgen! 宏 | C4: 版本兼容性 |
| V2 | Host 调用插件导出函数 | init / handle_event 能否正常工作 |
| V3 | 插件调用 Host 导入函数 | db_insert / log_write 能否正常回调 |
| V4 | async 支持 | Host async 函数(数据库操作)能否正确桥接 |
| V5 | Fuel + Epoch 资源限制 | 是否能限制插件 CPU 时间和内存 |
| V6 | 从二进制动态加载 | 从数据库/文件加载 WASM 并实例化 |
### 5.2 原型项目结构
在 workspace 中创建独立的原型 crate不影响现有代码
```
crates/
erp-plugin-prototype/ ← 新增原型 crate
Cargo.toml
wit/
plugin.wit ← WIT 接口定义
src/
lib.rs ← Host 端:运行时 + Host API 实现
main.rs ← 测试入口:加载插件并调用
tests/
test_plugin_integration.rs ← 集成测试
erp-plugin-test-sample/ ← 新增测试插件 crate
Cargo.toml
src/
lib.rs ← 插件端:实现 Guest trait
```
### 5.3 WIT 接口(验证用最小子集)
```wit
package erp:plugin;
interface host {
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
log-write: func(level: string, message: string);
}
interface plugin {
init: func() -> result<_, string>;
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
}
world plugin-world {
import host;
export plugin;
}
```
### 5.4 Host 端实现(验证要点)
```rust
// crates/erp-plugin-prototype/src/lib.rs
use wasmtime::component::*;
use wasmtime::{Config, Engine, Store};
use wasmtime::StoreLimitsBuilder;
// bindgen! 生成类型化绑定
bindgen!({
path: "./wit/plugin.wit",
world: "plugin-world",
async: true, // ← 验证 V4: async 支持
imports: { default: async | trappable },
exports: { default: async },
});
struct HostState {
fuel_consumed: u64,
logs: Vec<(String, String)>,
db_ops: Vec<(String, Vec<u8>)>,
}
// 实现 bindgen 生成的 Host trait
impl Host for HostState {
async fn db_insert(&mut self, entity: String, data: Vec<u8>) -> Result<Vec<u8>, String> {
// 模拟数据库操作
self.db_ops.push((entity, data.clone()));
Ok(br#"{"id":"test-uuid","tenant_id":"tenant-1"}"#.to_vec())
}
async fn log_write(&mut self, level: String, message: String) {
self.logs.push((level, message));
}
}
```
### 5.5 测试插件
```rust
// crates/erp-plugin-test-sample/src/lib.rs
wit_bindgen::generate!({
path: "../../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
struct TestPlugin;
impl Guest for TestPlugin {
fn init() -> Result<(), String> {
host::log_write("info", "测试插件初始化成功");
Ok(())
}
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
// 调用 Host API 验证双向通信
let result = host::db_insert("test_entity", br#"{"name":"test"}"#.to_vec())
.map_err(|e| format!("db_insert 失败: {}", e))?;
host::log_write("info", &format!("处理事件 {} 成功", event_type));
Ok(())
}
}
export!(TestPlugin);
```
### 5.6 验证测试用例
```rust
// crates/erp-plugin-prototype/tests/test_plugin_integration.rs
#[tokio::test]
async fn test_plugin_lifecycle() {
// V1: WIT 接口 + bindgen 编译通过(隐式验证)
// V6: 从文件加载 WASM 二进制
let wasm_bytes = std::fs::read("../erp-plugin-test-sample/target/.../test_plugin.wasm")
.expect("请先编译测试插件");
let engine = setup_engine(); // V5: 启用 fuel + epoch
let module = Module::from_binary(&engine, &wasm_bytes).unwrap();
let mut store = setup_store(&engine); // V5: 设置资源限制
let instance = instantiate(&mut store, &module).await.unwrap();
// V2: Host 调用插件 init()
instance.plugin().call_init(&mut store).await.unwrap();
// V3: Host 调用插件 handle_event(),插件回调 Host API
instance.plugin().call_handle_event(
&mut store,
"test.event".to_string(),
vec![],
).await.unwrap();
// 验证 Host 端收到了插件的操作
let state = store.data();
assert!(state.logs.iter().any(|(l, m)| m.contains("测试插件初始化成功")));
assert_eq!(state.db_ops.len(), 1);
}
#[tokio::test]
async fn test_fuel_limit() {
// V5: 验证 fuel 耗尽时正确 trap
let mut store = setup_store_with_fuel(100); // 极低 fuel
let result = instance.plugin().call_init(&mut store).await;
assert!(result.is_err()); // 应该因 fuel 耗尽而失败
}
```
### 5.7 验证步骤
```
步骤 1: 添加 crate 和 Cargo.toml 依赖
- crates/erp-plugin-prototype/Cargo.toml
wasmtime = "43", wasmtime-wasi = "43", tokio, anyhow
- crates/erp-plugin-test-sample/Cargo.toml
wit-bindgen = "0.55", serde, serde_json, crate-type = ["cdylib"]
步骤 2: 编写 WIT 接口文件
- crates/erp-plugin-prototype/wit/plugin.wit
步骤 3: 实现 Host 端bindgen + Host trait
- crates/erp-plugin-prototype/src/lib.rs
步骤 4: 实现测试插件
- crates/erp-plugin-test-sample/src/lib.rs
步骤 5: 编译测试插件为 WASM
- cargo build -p erp-plugin-test-sample --target wasm32-unknown-unknown --release
步骤 6: 运行集成测试
- cargo test -p erp-plugin-prototype
步骤 7: 验证资源限制
- fuel 耗尽 trap
- 内存限制
- epoch 中断
```
### 5.8 验证成功标准
| 标准 | 衡量方式 |
|------|---------|
| V1 编译通过 | Host 和插件 crate 均能 `cargo check` 通过 |
| V2 Host→插件调用 | `init()` 返回 OkHost 端日志记录初始化成功 |
| V3 插件→Host回调 | `handle_event()` 中调用 `host::db_insert()` 成功返回数据 |
| V4 async 正确 | Host 的 async db_insert 在 tokio runtime 中正确执行 |
| V5 资源限制 | 低 fuel 时 init() 返回错误而非无限循环 |
| V6 动态加载 | 从 .wasm 文件加载并实例化成功 |
| 编译大小 | 测试插件 WASM < 2MB |
| 启动耗时 | 单个插件实例化 < 100ms |
### 5.9 关键文件清单
| 文件 | 用途 |
|------|------|
| `crates/erp-plugin-prototype/Cargo.toml` | 新建 - Host crate 配置 |
| `crates/erp-plugin-prototype/wit/plugin.wit` | 新建 - WIT 接口定义 |
| `crates/erp-plugin-prototype/src/lib.rs` | 新建 - Host 运行时 + API 实现 |
| `crates/erp-plugin-prototype/src/main.rs` | 新建 - 手动测试入口 |
| `crates/erp-plugin-prototype/tests/test_plugin_integration.rs` | 新建 - 集成测试 |
| `crates/erp-plugin-test-sample/Cargo.toml` | 新建 - 插件 crate 配置 |
| `crates/erp-plugin-test-sample/src/lib.rs` | 新建 - 测试插件实现 |
| `Cargo.toml` | 修改 - 添加两个新 workspace member |

View File

@@ -0,0 +1,763 @@
# ERP 插件管理系统 — 完整实施计划
## Context
ERP 平台已完成 Phase 1-6基础设施、身份权限、系统配置、工作流、消息中心、整合打磨WASM 插件原型 V1-V6 已验证通过。现在需要将原型集成到生产系统,形成**完整的插件管理链路**:开发 → 打包 → 上传 → 安装 → 启用 → 运行 → 停用 → 卸载。
**当前差距**:原型使用 mock HostState无真实 DB 操作;无插件管理 API无数据库表无前端管理界面无动态表/路由。
---
## 阶段依赖图
```
7A (基础设施升级) → 7B (插件运行时) → 7C (数据库表) → 7D (管理 API) → 7E (数据 CRUD API)
8A (前端 API + Store) → 8B (管理页面) → 8C (动态路由 + CRUD 页面) → 8D (E2E 验证)
7D 完成 → 8B 可开始
7E 完成 → 8C 可开始
```
---
## Phase 7A: 基础设施升级
**目标**: 扩展 ErpModule trait、ModuleRegistry、EventBus全部向后兼容现有 4 个模块无需修改。
### 7A.1 EventBus 过滤订阅
**修改**: [events.rs](crates/erp-core/src/events.rs)
```rust
// 新增方法
pub fn subscribe_filtered(
&self,
event_type_prefix: String,
) -> (FilteredEventReceiver, SubscriptionHandle)
// 新增类型
pub struct FilteredEventReceiver { /* mpsc::Receiver */ }
impl FilteredEventReceiver {
pub async fn recv(&mut self) -> Option<DomainEvent> { ... }
}
pub struct SubscriptionHandle { /* JoinHandle + sender for cancel */ }
impl SubscriptionHandle {
pub fn cancel(self) { ... }
}
```
实现:为每次 `subscribe_filtered` 调用 spawn 一个 Tokio task从 broadcast channel 读取,过滤匹配 `event_type_prefix` 的事件转发到 mpsc channelcapacity 256
### 7A.2 ErpModule Trait v2
**修改**: [module.rs](crates/erp-core/src/module.rs)
```rust
// 新增枚举
pub enum ModuleType { Builtin, Plugin }
// 新增上下文结构
pub struct ModuleContext {
pub db: sea_orm::DatabaseConnection,
pub event_bus: EventBus,
}
// trait 新增方法(全部有默认实现)
fn id(&self) -> &str { self.name() }
fn module_type(&self) -> ModuleType { ModuleType::Builtin }
async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
async fn on_shutdown(&self) -> AppResult<()> { Ok(()) }
async fn health_check(&self) -> AppResult<serde_json::Value> {
Ok(serde_json::json!({"status": "healthy"}))
}
```
### 7A.3 ModuleRegistry v2
**修改**: [module.rs](crates/erp-core/src/module.rs)
```rust
impl ModuleRegistry {
// 新增方法
pub fn sorted_modules(&self) -> Vec<Arc<dyn ErpModule>> // 拓扑排序
pub async fn startup_all(&self, ctx: &ModuleContext) -> AppResult<()>
pub async fn shutdown_all(&self) -> AppResult<()>
pub async fn health_check_all(&self) -> Vec<(String, AppResult<serde_json::Value>)>
pub fn get_module(&self, name: &str) -> Option<Arc<dyn ErpModule>>
}
```
拓扑排序: Kahn 算法,环检测返回 `AppError::Validation`
### 7A.4 服务启动集成
**修改**: [main.rs](crates/erp-server/src/main.rs) — 在 `registry.register_handlers` 之后添加:
```rust
let module_ctx = ModuleContext { db: db.clone(), event_bus: event_bus.clone() };
registry.startup_all(&module_ctx).await?;
```
**修改**: [lib.rs](crates/erp-core/src/lib.rs) — 导出新类型 `ModuleType`, `ModuleContext`
**7A 文件清单**:
| 操作 | 文件 |
|------|------|
| 修改 | `crates/erp-core/src/events.rs` |
| 修改 | `crates/erp-core/src/module.rs` |
| 修改 | `crates/erp-core/src/lib.rs` |
| 修改 | `crates/erp-server/src/main.rs` |
---
## Phase 7B: 插件运行时 Crate
**目标**: 创建 `erp-plugin` crate实现生产级 Host API真实 DB/EventBus 操作)。
### 7B.1 Crate 骨架
**新建**: `crates/erp-plugin/Cargo.toml`
```toml
[dependencies]
wasmtime = "43"
wasmtime-wasi = "43"
erp-core.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
sea-orm.workspace = true
uuid.workspace = true
chrono.workspace = true
tracing.workspace = true
thiserror.workspace = true
dashmap = "6"
toml = "0.8"
```
**新建**: `crates/erp-plugin/wit/plugin.wit` — 从 prototype 复制
**新建**: `crates/erp-plugin/src/lib.rs` — 声明模块:`engine`, `host`, `manifest`, `state`, `error`, `dynamic_table`, `entity`, `dto`, `service`, `handler`, `data_service`, `data_dto`, `module`
**修改**: [Cargo.toml](Cargo.toml) — workspace members 添加 `"crates/erp-plugin"`dependencies 添加 `erp-plugin = { path = "crates/erp-plugin" }`
### 7B.2 错误类型
**新建**: `crates/erp-plugin/src/error.rs`
```rust
pub enum PluginError {
NotFound(String),
AlreadyExists(String),
InvalidManifest(String),
InvalidState { expected: String, actual: String },
ExecutionError(String),
InstantiationError(String),
FuelExhausted(String),
DependencyNotSatisfied(String),
DatabaseError(String),
PermissionDenied(String),
}
// From<PluginError> for AppError
pub type PluginResult<T> = Result<T, PluginError>;
```
### 7B.3 插件清单解析
**新建**: `crates/erp-plugin/src/manifest.rs`
```rust
pub struct PluginManifest {
pub metadata: PluginMetadata,
pub schema: Option<PluginSchema>,
pub events: Option<PluginEvents>,
pub ui: Option<PluginUi>,
pub permissions: Option<Vec<PluginPermission>>,
}
pub struct PluginMetadata { id, name, version, description, author, min_platform_version, dependencies }
pub struct PluginSchema { pub entities: Vec<PluginEntity> }
pub struct PluginEntity { name, display_name, fields: Vec<PluginField>, indexes }
pub struct PluginField { name, field_type: PluginFieldType, required, unique, default, display_name, ui_widget, options }
pub enum PluginFieldType { String, Integer, Float, Boolean, Date, DateTime, Json, Uuid, Decimal }
pub struct PluginEvents { pub subscribe: Vec<String> }
pub struct PluginUi { pub pages: Vec<PluginPage> }
pub struct PluginPage { route, entity, display_name, icon, menu_group }
pub struct PluginPermission { code, name, description }
pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest>
```
### 7B.4 生产 Host 实现(延迟执行模式)
**新建**: `crates/erp-plugin/src/host.rs`
**关键设计**: WASM 调用是同步的SeaORM 是异步的。采用**延迟执行模式**
- 读操作db_query, config_get, current_user→ 调用前预填充 HostStateHost 方法直接返回缓存数据
- 写操作db_insert, db_update, db_delete, event_publish→ Host 方法将操作入队到 `self.pending_ops`,返回合成成功响应
- WASM 调用结束后engine 刷新 `pending_ops` 执行真实 DB 操作
```rust
pub struct HostState {
pub(crate) limits: StoreLimits,
pub(crate) tenant_id: Uuid,
pub(crate) user_id: Uuid,
pub(crate) permissions: Vec<String>,
pub(crate) plugin_id: String,
// 预填充的读取缓存
pub(crate) query_results: HashMap<String, Vec<u8>>,
pub(crate) config_cache: HashMap<String, Vec<u8>>,
pub(crate) current_user_json: Vec<u8>,
// 待刷新的写操作
pub(crate) pending_ops: Vec<PendingOp>,
pub(crate) logs: Vec<(String, String)>,
}
pub enum PendingOp {
Insert { entity: String, data: Vec<u8> },
Update { entity: String, id: String, data: Vec<u8>, version: i64 },
Delete { entity: String, id: String },
PublishEvent { event_type: String, payload: Vec<u8> },
}
```
### 7B.5 插件引擎
**新建**: `crates/erp-plugin/src/engine.rs`
```rust
pub struct PluginEngine {
engine: wasmtime::Engine,
db: DatabaseConnection,
event_bus: EventBus,
plugins: Arc<DashMap<String, LoadedPlugin>>,
config: PluginEngineConfig,
}
pub struct PluginEngineConfig {
pub default_fuel: u64, // 10_000_000
pub execution_timeout_secs: u64, // 30
}
pub struct LoadedPlugin {
pub id: String,
pub manifest: PluginManifest,
pub component: Component,
pub linker: Linker<HostState>,
pub status: PluginStatus,
pub event_handle: Option<SubscriptionHandle>,
}
pub enum PluginStatus { Loaded, Initialized, Running, Error(String), Disabled }
// 核心方法
impl PluginEngine {
pub fn new(db, event_bus, config) -> Result<Self>
pub async fn load(&self, plugin_id, wasm_bytes, manifest) -> Result<()> // 加载到内存
pub async fn initialize(&self, plugin_id) -> Result<()> // 调用 init()
pub async fn start_event_listener(&self, plugin_id) -> Result<()> // 订阅事件
pub async fn handle_event(&self, plugin_id, event_type, payload, tenant_id, user_id) -> Result<()>
pub async fn on_tenant_created(&self, plugin_id, tenant_id) -> Result<()>
pub async fn disable(&self, plugin_id) -> Result<()> // 停止+卸载
pub async fn unload(&self, plugin_id) -> Result<()>
pub async fn health_check(&self, plugin_id) -> Result<serde_json::Value>
pub fn list_plugins(&self) -> Vec<PluginInfo>
pub fn get_manifest(&self, plugin_id) -> Option<PluginManifest>
// 内部: spawn_blocking + catch_unwind + fuel 限制 + timeout
async fn execute_wasm<F, R>(&self, plugin_id, operation: F) -> Result<R>
// 内部: 刷新 pending_ops 到真实 DB
async fn flush_ops(&self, state: &HostState) -> Result<()>
}
```
`execute_wasm` 流程:
1. 从 DashMap 获取 LoadedPlugin
2. 创建新 Store + HostState预填充读数据
3. `tokio::task::spawn_blocking` 包装 WASM 调用
4. 内部 `std::panic::catch_unwind(AssertUnwindSafe(...))`
5. 返回后 `flush_ops` 执行真实 DB 操作
6. 外层 `tokio::time::timeout` 限制执行时间
### 7B.6 动态表管理器
**新建**: `crates/erp-plugin/src/dynamic_table.rs`
```rust
pub struct DynamicTableManager;
impl DynamicTableManager {
pub async fn create_table(db, plugin_id, entity: &PluginEntity) -> Result<()>
pub async fn drop_table(db, plugin_id, entity_name) -> Result<()>
pub async fn table_exists(db, table_name) -> Result<bool>
pub fn table_name(plugin_id, entity_name) -> String // "plugin_{sanitized_id}_{entity}"
pub fn build_insert_sql(table_name, data) -> (String, Vec<Value>)
pub fn build_query_sql(table_name, filter, pagination) -> (String, Vec<Value>)
pub fn build_update_sql(table_name, id, data, version) -> (String, Vec<Value>)
pub fn build_delete_sql(table_name, id) -> (String, Vec<Value>)
}
```
动态表结构: `plugin_{id}_{entity}` 列包括 id(UUID PK), tenant_id, data(JSONB), created_at, updated_at, created_by, updated_by, deleted_at, version
### 7B.7 插件状态
**新建**: `crates/erp-plugin/src/state.rs`
```rust
#[derive(Clone)]
pub struct PluginState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub engine: PluginEngine,
}
```
**7B 文件清单**:
| 操作 | 文件 |
|------|------|
| 新建 | `crates/erp-plugin/Cargo.toml` |
| 新建 | `crates/erp-plugin/wit/plugin.wit` |
| 新建 | `crates/erp-plugin/src/lib.rs` |
| 新建 | `crates/erp-plugin/src/error.rs` |
| 新建 | `crates/erp-plugin/src/manifest.rs` |
| 新建 | `crates/erp-plugin/src/host.rs` |
| 新建 | `crates/erp-plugin/src/engine.rs` |
| 新建 | `crates/erp-plugin/src/state.rs` |
| 新建 | `crates/erp-plugin/src/dynamic_table.rs` |
| 修改 | `Cargo.toml` (workspace) |
---
## Phase 7C: 数据库表
**目标**: 创建插件元数据表 + SeaORM Entity。
### 7C.1 迁移文件
**新建**: `crates/erp-server/migration/src/m20260417_000033_create_plugins.rs`
三张表:
**plugins** — 插件注册与生命周期
| 列 | 类型 | 说明 |
|---|---|---|
| id | UUID PK | manifest 中的 ID |
| tenant_id | UUID NOT NULL | 所属租户 |
| name | VARCHAR(200) | 插件名称 |
| plugin_version | VARCHAR(50) | 语义版本 |
| description | TEXT | |
| author | VARCHAR(200) | |
| status | VARCHAR(20) | uploaded/installed/enabled/running/disabled/uninstalled |
| manifest_json | JSONB | 完整清单 |
| wasm_binary | BYTEA | WASM 二进制 |
| wasm_hash | VARCHAR(64) | SHA-256 |
| config_json | JSONB DEFAULT '{}' | 插件配置 |
| error_message | TEXT | 最近错误 |
| installed_at | TIMESTAMPTZ | |
| enabled_at | TIMESTAMPTZ | |
| + 标准字段 | | created_at, updated_at, created_by, updated_by, deleted_at, version |
索引: `idx_plugins_tenant_status`, `idx_plugins_name` (均 WHERE deleted_at IS NULL)
**plugin_entities** — 插件动态表注册
| 列 | 类型 | 说明 |
|---|---|---|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| plugin_id | UUID → plugins(id) | |
| entity_name | VARCHAR(100) | |
| table_name | VARCHAR(200) | 实际表名 |
| schema_json | JSONB | 字段定义 |
| + 标准字段 | | |
**plugin_event_subscriptions** — 事件订阅
| 列 | 类型 | 说明 |
|---|---|---|
| id | UUID PK | |
| plugin_id | UUID → plugins(id) | |
| event_pattern | VARCHAR(200) | 如 "workflow.task.*" |
| created_at | TIMESTAMPTZ | |
**修改**: [migration/lib.rs](crates/erp-server/migration/src/lib.rs) — 注册新迁移
### 7C.2 SeaORM Entity
**新建**: `crates/erp-plugin/src/entity/mod.rs`
**新建**: `crates/erp-plugin/src/entity/plugin.rs` — plugins 表 Entity
**新建**: `crates/erp-plugin/src/entity/plugin_entity.rs` — plugin_entities 表 Entity
**新建**: `crates/erp-plugin/src/entity/plugin_event_subscription.rs` — 事件订阅 Entity
每个 Entity 遵循标准模式: DeriveEntityModel, Relation, ActiveModelBehavior
**7C 文件清单**:
| 操作 | 文件 |
|------|------|
| 新建 | `crates/erp-server/migration/src/m20260417_000033_create_plugins.rs` |
| 新建 | `crates/erp-plugin/src/entity/mod.rs` |
| 新建 | `crates/erp-plugin/src/entity/plugin.rs` |
| 新建 | `crates/erp-plugin/src/entity/plugin_entity.rs` |
| 新建 | `crates/erp-plugin/src/entity/plugin_event_subscription.rs` |
| 修改 | `crates/erp-server/migration/src/lib.rs` |
---
## Phase 7D: 插件管理 API
**目标**: 构建 admin REST API 实现完整插件生命周期管理。
### 7D.1 DTO
**新建**: `crates/erp-plugin/src/dto.rs`
```rust
// Response
pub struct PluginResp { id, name, version, description, author, status, config, installed_at, enabled_at, entities, permissions, version }
pub struct PluginEntityResp { name, display_name, table_name }
pub struct PluginHealthResp { plugin_id, status, details }
// Request
pub struct UpdatePluginConfigReq { config: serde_json::Value, version: i32 }
// Query
pub struct PluginListParams { page, page_size, status, search }
```
### 7D.2 Service
**新建**: `crates/erp-plugin/src/service.rs`
```rust
pub struct PluginService;
impl PluginService {
pub async fn upload(tenant_id, operator_id, wasm_binary, manifest_toml, db) -> AppResult<PluginResp>
pub async fn install(plugin_id, tenant_id, operator_id, db, engine) -> AppResult<PluginResp>
pub async fn enable(plugin_id, tenant_id, operator_id, db, engine) -> AppResult<PluginResp>
pub async fn disable(plugin_id, tenant_id, operator_id, db, engine) -> AppResult<PluginResp>
pub async fn uninstall(plugin_id, tenant_id, operator_id, db, engine) -> AppResult<PluginResp>
pub async fn list(tenant_id, pagination, status, search, db) -> AppResult<(Vec<PluginResp>, u64)>
pub async fn get_by_id(plugin_id, tenant_id, db) -> AppResult<PluginResp>
pub async fn update_config(plugin_id, tenant_id, operator_id, req, db) -> AppResult<PluginResp>
pub async fn health_check(plugin_id, tenant_id, db, engine) -> AppResult<PluginHealthResp>
pub async fn get_schema(plugin_id, tenant_id, db) -> AppResult<serde_json::Value>
}
```
生命周期状态机: `uploaded → installed → enabled/running → disabled → uninstalled`
- upload: 解析 manifest + 存储 wasm_binary + status=uploaded
- install: 创建动态表 + 注册 entity 记录 + 注册事件订阅 + status=installed
- enable: engine.load + engine.initialize + engine.start_event_listener + status=running
- disable: engine.disable + cancel 事件订阅 + status=disabled
- uninstall: disable(如运行中) + drop 动态表 + status=uninstalled
### 7D.3 Handlers
**新建**: `crates/erp-plugin/src/handler/mod.rs`
**新建**: `crates/erp-plugin/src/handler/plugin_handler.rs`
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/admin/plugins/upload` | 上传 (multipart: wasm + manifest) |
| GET | `/admin/plugins` | 列表 (分页+过滤) |
| GET | `/admin/plugins/{id}` | 详情 |
| GET | `/admin/plugins/{id}/schema` | 实体 schema |
| POST | `/admin/plugins/{id}/install` | 安装 |
| POST | `/admin/plugins/{id}/enable` | 启用 |
| POST | `/admin/plugins/{id}/disable` | 停用 |
| POST | `/admin/plugins/{id}/uninstall` | 卸载 |
| DELETE | `/admin/plugins/{id}` | 清除 |
| GET | `/admin/plugins/{id}/health` | 健康检查 |
| PUT | `/admin/plugins/{id}/config` | 更新配置 |
所有 handler 遵循现有模式: `State<PluginState>`, `Extension<TenantContext>`, `require_permission("plugin.admin")`, utoipa 注解
### 7D.4 Module 注册
**新建**: `crates/erp-plugin/src/module.rs`
```rust
pub struct PluginModule;
impl ErpModule for PluginModule { name="plugin", dependencies=["auth","config"], module_type=Builtin }
impl PluginModule {
pub fn protected_routes<S>() -> Router<S> // 上述所有路由
}
```
### 7D.5 服务端集成
**修改**: [main.rs](crates/erp-server/src/main.rs)
- 创建 `PluginEngine::new(db.clone(), event_bus.clone(), config)`
- 注册 `PluginModule` 到 registry
- 合并 `PluginModule::protected_routes()` 到 protected_routes
- 启动时恢复已 enabled 的插件: 查询 plugins 表 → engine.load + initialize + start_event_listener
**修改**: [state.rs](crates/erp-server/src/state.rs)
- AppState 新增 `pub plugin_engine: erp_plugin::engine::PluginEngine`
- 添加 `FromRef<AppState> for erp_plugin::PluginState`
**修改**: [seed.rs](crates/erp-auth/src/service/seed.rs) — 添加 `plugin.admin`, `plugin.list` 权限种子
**修改**: [Cargo.toml](crates/erp-server/Cargo.toml) — 添加 `erp-plugin.workspace = true`
**7D 文件清单**:
| 操作 | 文件 |
|------|------|
| 新建 | `crates/erp-plugin/src/dto.rs` |
| 新建 | `crates/erp-plugin/src/service.rs` |
| 新建 | `crates/erp-plugin/src/handler/mod.rs` |
| 新建 | `crates/erp-plugin/src/handler/plugin_handler.rs` |
| 新建 | `crates/erp-plugin/src/module.rs` |
| 修改 | `crates/erp-server/src/main.rs` |
| 修改 | `crates/erp-server/src/state.rs` |
| 修改 | `crates/erp-server/Cargo.toml` |
| 修改 | `crates/erp-auth/src/service/seed.rs` |
---
## Phase 7E: 插件数据 CRUD API
**目标**: 通用数据 CRUD 端点 `/api/v1/plugins/{plugin_id}/{entity}/*`
### 7E.1 数据 DTO
**新建**: `crates/erp-plugin/src/data_dto.rs`
```rust
pub struct PluginDataResp { id, data: serde_json::Value, created_at, updated_at, version }
pub struct CreatePluginDataReq { data: serde_json::Value }
pub struct UpdatePluginDataReq { data: serde_json::Value, version: i32 }
pub struct PluginDataListParams { page, page_size, search }
```
### 7E.2 数据 Service
**新建**: `crates/erp-plugin/src/data_service.rs`
```rust
pub struct PluginDataService;
impl PluginDataService {
pub async fn create(plugin_id, entity_name, tenant_id, operator_id, req, db, event_bus) -> AppResult<PluginDataResp>
pub async fn list(plugin_id, entity_name, tenant_id, pagination, search, db) -> AppResult<(Vec<PluginDataResp>, u64)>
pub async fn get_by_id(plugin_id, entity_name, id, tenant_id, db) -> AppResult<PluginDataResp>
pub async fn update(plugin_id, entity_name, id, tenant_id, operator_id, req, db, event_bus) -> AppResult<PluginDataResp>
pub async fn delete(plugin_id, entity_name, id, tenant_id, operator_id, db, event_bus) -> AppResult<()>
}
```
每个方法: 解析 table_name → 验证插件 running → 执行原始参数化 SQL → 发布 domain event → 审计日志
### 7E.3 数据 Handler
**新建**: `crates/erp-plugin/src/handler/data_handler.rs`
| 方法 | 路径 |
|------|------|
| GET | `/plugins/{plugin_id}/{entity}` |
| POST | `/plugins/{plugin_id}/{entity}` |
| GET | `/plugins/{plugin_id}/{entity}/{id}` |
| PUT | `/plugins/{plugin_id}/{entity}/{id}` |
| DELETE | `/plugins/{plugin_id}/{entity}/{id}` |
权限: `plugin.{plugin_id}.{entity}.{action}`
**修改**: [module.rs](crates/erp-plugin/src/module.rs) — 添加数据路由
**7E 文件清单**:
| 操作 | 文件 |
|------|------|
| 新建 | `crates/erp-plugin/src/data_dto.rs` |
| 新建 | `crates/erp-plugin/src/data_service.rs` |
| 新建 | `crates/erp-plugin/src/handler/data_handler.rs` |
| 修改 | `crates/erp-plugin/src/module.rs` |
---
## Phase 8A: 前端 API + Store
### 8A.1 插件 API 模块
**新建**: `apps/web/src/api/plugins.ts`
```typescript
export interface PluginInfo { id, name, version, description, author, status, config, installed_at, enabled_at, entities, permissions, version }
export type PluginStatus = 'uploaded' | 'installed' | 'enabled' | 'running' | 'disabled' | 'uninstalled'
export interface PluginEntityInfo { name, display_name, table_name }
export async function listPlugins(page, pageSize, status?)
export async function getPlugin(id)
export async function uploadPlugin(file: File, manifest: string)
export async function installPlugin(id)
export async function enablePlugin(id)
export async function disablePlugin(id)
export async function uninstallPlugin(id)
export async function purgePlugin(id)
export async function getPluginHealth(id)
export async function updatePluginConfig(id, config, version)
export async function getPluginSchema(id)
```
### 8A.2 插件数据 API
**新建**: `apps/web/src/api/pluginData.ts`
```typescript
export interface PluginDataRecord { id, data: Record<string,unknown>, created_at, updated_at, version }
export async function listPluginData(pluginId, entity, page?, pageSize?)
export async function getPluginData(pluginId, entity, id)
export async function createPluginData(pluginId, entity, data)
export async function updatePluginData(pluginId, entity, id, data, version)
export async function deletePluginData(pluginId, entity, id)
```
### 8A.3 Plugin Store
**新建**: `apps/web/src/stores/plugin.ts`
```typescript
interface PluginStore {
plugins: PluginInfo[]
loading: boolean
pluginMenuItems: PluginMenuItem[]
fetchPlugins: (page?, status?) => Promise<void>
refreshMenuItems: () => void
}
interface PluginMenuItem { key: string, icon: string, label: string, pluginId: string, entity: string, menuGroup?: string }
```
**8A 文件清单**:
| 操作 | 文件 |
|------|------|
| 新建 | `apps/web/src/api/plugins.ts` |
| 新建 | `apps/web/src/api/pluginData.ts` |
| 新建 | `apps/web/src/stores/plugin.ts` |
---
## Phase 8B: 插件管理页面
### 8B.1 PluginAdmin 页面
**新建**: `apps/web/src/pages/PluginAdmin.tsx`
遵循 Users.tsx 模式:
- Table 列: name, version, status(Tag 颜色), author, actions
- Upload Modal: Upload 组件(拖拽 .wasm) + TextArea(manifest TOML)
- Detail Drawer: manifest JSON, entities, config, health
- Actions 按钮根据 status 动态显示: Install/Enable/Disable/Uninstall
```typescript
const STATUS_CONFIG = {
uploaded: { color: '#64748B', label: '已上传' },
installed: { color: '#2563EB', label: '已安装' },
enabled: { color: '#059669', label: '已启用' },
running: { color: '#059669', label: '运行中' },
disabled: { color: '#DC2626', label: '已禁用' },
uninstalled: { color: '#9333EA', label: '已卸载' },
}
```
### 8B.2 路由 + 侧边栏
**修改**: [App.tsx](apps/web/src/App.tsx) — 添加 `lazy(() => import('./pages/PluginAdmin'))` + `<Route path="/plugins/admin" ...>`
**修改**: [MainLayout.tsx](apps/web/src/layouts/MainLayout.tsx)
- sysMenuItems 添加 `{ key: '/plugins/admin', icon: <AppstoreOutlined />, label: '插件管理' }`
- routeTitleMap 添加 `'/plugins/admin': '插件管理'`
- 添加动态插件菜单组(从 pluginStore.pluginMenuItems 生成)
**8B 文件清单**:
| 操作 | 文件 |
|------|------|
| 新建 | `apps/web/src/pages/PluginAdmin.tsx` |
| 修改 | `apps/web/src/App.tsx` |
| 修改 | `apps/web/src/layouts/MainLayout.tsx` |
---
## Phase 8C: 动态路由 + PluginCRUDPage
### 8C.1 PluginCRUDPage
**新建**: `apps/web/src/pages/PluginCRUDPage.tsx`
通用配置驱动 CRUD 页面:
- 从 URL params 获取 `pluginId` + `entityName`
- 调用 `getPluginSchema(pluginId)` 获取字段定义
- 自动生成 Table columns从 entity.fields
- 自动生成 Form fields根据 ui_widget: text/number/select/date/switch
- CRUD 操作调用 pluginData API
```typescript
export default function PluginCRUDPage() {
const { pluginId, entityName } = useParams();
// fetch schema → generate columns → render Table + Modal form
}
```
### 8C.2 动态路由
**修改**: [App.tsx](apps/web/src/App.tsx) — 添加:
```typescript
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
```
**8C 文件清单**:
| 操作 | 文件 |
|------|------|
| 新建 | `apps/web/src/pages/PluginCRUDPage.tsx` |
| 修改 | `apps/web/src/App.tsx` |
---
## Phase 8D: E2E 验证
### 8D.1 测试插件 manifest
**新建**: `crates/erp-plugin-test-sample/plugin.toml` — 包含完整 schema/events/ui/permissions 定义
**修改**: `crates/erp-plugin-test-sample/src/lib.rs` — 适配最终 WIT 接口
### 8D.2 启动时恢复插件
**修改**: [main.rs](crates/erp-server/src/main.rs) — 启动时查询 plugins(status=running) → 逐个 engine.load + initialize + start_event_listener
### 8D.3 验证清单
手动 E2E 测试流程:
1. 编译测试插件: `cargo build -p erp-plugin-test-sample --target wasm32-unknown-unknown --release`
2. 转换: `wasm-tools component new ... -o target/test-sample.component.wasm`
3. 打包 manifest.toml + .component.wasm
4. 通过 PluginAdmin 上传
5. 安装 → 验证动态表创建
6. 启用 → 验证 init() 调用成功
7. 通过 PluginCRUDPage 创建/读取/更新/删除数据
8. 触发 workflow.task.completed 事件 → 验证插件 handle_event 被调用
9. 停用 → 验证事件订阅取消
10. 卸载 → 验证动态表清理
---
## 文件统计
| Phase | 新建 | 修改 | 合计 |
|-------|------|------|------|
| 7A | 0 | 4 | 4 |
| 7B | 9 | 1 | 10 |
| 7C | 5 | 1 | 6 |
| 7D | 5 | 4 | 9 |
| 7E | 3 | 1 | 4 |
| 8A | 3 | 0 | 3 |
| 8B | 1 | 2 | 3 |
| 8C | 1 | 1 | 2 |
| 8D | 1 | 2 | 3 |
| **合计** | **28** | **16** | **44** |
## 验证方式
每个 Phase 完成后:
- `cargo check` 全 workspace 编译通过
- `cargo test --workspace` 测试通过
- Phase 7 完成后: `cargo run -p erp-server` 启动成功API 端点可用
- Phase 8 完成后: `pnpm dev` 前端启动PluginAdmin 页面可访问,完整 CRUD 链路可用