fix(用户管理): 修复用户列表页面加载失败问题
修复用户列表页面加载失败导致测试超时的问题,确保页面元素正确渲染
This commit is contained in:
61
plans/cheeky-shimmying-jellyfish.md
Normal file
61
plans/cheeky-shimmying-jellyfish.md
Normal 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. 页面标题(面包屑)正确显示
|
||||
667
plans/flickering-tinkering-pebble-agent-a409e20167941384b.md
Normal file
667
plans/flickering-tinkering-pebble-agent-a409e20167941384b.md
Normal 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 解决方案
|
||||
|
||||
**方案 A:Host 端提供递归查询 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 2:CRM 核心功能(约 5-7 天)**
|
||||
|
||||
1. 创建 CRM 插件 crate(5 个实体)
|
||||
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 个)。
|
||||
561
plans/flickering-tinkering-pebble-agent-a785dda8d2f4eeebc.md
Normal file
561
plans/flickering-tinkering-pebble-agent-a785dda8d2f4eeebc.md
Normal 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 fuel,20 次调用约 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`。
|
||||
|
||||
**Bug:unique 字段没有创建唯一索引。**
|
||||
|
||||
此外,`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)
|
||||
- 筛选器:时间范围、客户类型等
|
||||
- 实时更新(可选)
|
||||
|
||||
**关键依赖**:需要后端提供聚合查询 API(COUNT/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 + tabs(CRM 核心需要)
|
||||
- 第二期(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. 整体实施风险评估
|
||||
|
||||
### 高风险
|
||||
|
||||
#### R1:db-query Host API 不可用(风险等级:高)
|
||||
|
||||
**问题**:当前 `db_query` 依赖预填充,但 `execute_wasm` 中没有预填充逻辑。CRM 插件在 WASM 内部无法执行关联查询。
|
||||
|
||||
**影响**:WASM 内的 `handle_event` 无法查询关联数据。例如,收到 `workflow.task.completed` 事件后,无法查询关联的客户记录。
|
||||
|
||||
**缓解方案**:
|
||||
- 短期:CRM 的关联查询全部走前端 REST API,WASM 内只做简单的 `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 永远返回 false,JWT 中间件也无法加载这些权限。
|
||||
|
||||
**修复量**:约 50 行代码改动(install/uninstall 方法增加权限 CRUD)。
|
||||
|
||||
### 中风险
|
||||
|
||||
#### R4:REST API 不支持过滤查询(风险等级:中)
|
||||
|
||||
**问题**:`PluginDataListParams` 只有分页参数,不支持字段过滤。CRM 需要按 `customer_id` 过滤联系人等场景。
|
||||
|
||||
**修复量**:约 80 行代码改动(后端 filter 解析 + SQL 构建 + 前端传参)。
|
||||
|
||||
#### R5:前端 tabs 嵌套实现复杂度(风险等级:中)
|
||||
|
||||
**问题**:tabs 页面类型需要支持动态组合不同子页面类型,且子页面需要从父页面获取过滤参数。这涉及组件设计模式的选择。
|
||||
|
||||
**缓解方案**:先实现基本的 tabs + crud 组合,graph/timeline 作为子页面类型后续迭代。
|
||||
|
||||
#### R6:前端页面类型路由分发(风险等级:中)
|
||||
|
||||
**问题**:当前 `App.tsx` 只有一个 `PluginCRUDPage` 路由。需要根据 manifest 中的 `page_type` 动态选择渲染组件。
|
||||
|
||||
**修复量**:约 100 行代码改动(新增路由分发组件 + 各页面类型组件)。
|
||||
|
||||
### 低风险
|
||||
|
||||
#### R7:Fuel 限制(风险等级:低)
|
||||
|
||||
1000 万 fuel 对 CRM 插件绰绰有余。无需改动。
|
||||
|
||||
#### R8:manifest 扩展(风险等级:低)
|
||||
|
||||
新增的 `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` 支持条件过滤
|
||||
|
||||
### 分期实施计划
|
||||
|
||||
#### 第一期:最小可用 CRM(2-3 周)
|
||||
|
||||
- 5 个 JSONB 实体(customer/contact/communication/customer_tag/customer_relationship)
|
||||
- plugin.toml manifest 定义
|
||||
- WASM 插件 Rust crate(init + on_tenant_created + handle_event 基础逻辑)
|
||||
- 前端 CRUD 页面(复用已有 PluginCRUDPage)
|
||||
- 前端 tabs 页面类型(客户详情页的基本信息+联系人+沟通记录)
|
||||
- 前端 timeline 页面类型(沟通记录时间线)
|
||||
- REST API filter 支持
|
||||
|
||||
#### 第二期:增强功能(2-3 周)
|
||||
|
||||
- 前端 tree 页面类型(客户层级树)
|
||||
- 前端 dashboard 页面类型(CRM 概览统计)
|
||||
- 后端聚合查询 API(COUNT/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 运行时本身(它已被验证),而在于周边配套设施(权限、过滤、前端组件)的完善程度。
|
||||
512
plans/flickering-tinkering-pebble-agent-ae1f1bf7d07977a2d.md
Normal file
512
plans/flickering-tinkering-pebble-agent-ae1f1bf7d07977a2d.md
Normal 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 用 Select,date 用 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 的查询能力是整个方案的瓶颈
|
||||
76
plans/flickering-tinkering-pebble.md
Normal file
76
plans/flickering-tinkering-pebble.md
Normal 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 2(CRM 插件核心)→ 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 节。
|
||||
242
plans/fluffy-painting-bachman.md
Normal file
242
plans/fluffy-painting-bachman.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# CRM 插件平台标杆 — P0 基础能力设计
|
||||
|
||||
## Context
|
||||
|
||||
CRM 插件作为 ERP 平台的第一个行业插件,目前暴露了插件平台的多项基础能力缺口。本次设计的定位不是"CRM 功能最全",而是"插件平台能力最扎实"——通过 CRM 验证的每个能力都应被所有未来插件(inventory、生产、财务等)零改动复用。
|
||||
|
||||
对标一流 CRM(Salesforce/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. 知识库文档: 记录讨论过程和决策理由
|
||||
169
plans/merry-knitting-newt.md
Normal file
169
plans/merry-knitting-newt.md
Normal 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**:
|
||||
- 移除所有 props(pluginId/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**:
|
||||
- 移除 props(pluginId/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**:
|
||||
- 移除 props(pluginId/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 页面显示统计
|
||||
90
plans/optimized-orbiting-hickey.md
Normal file
90
plans/optimized-orbiting-hickey.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 审计问题修复计划(按优先级)
|
||||
|
||||
## Context
|
||||
|
||||
2026-04-18 系统全面审计发现多个问题(详见 `docs/audit-2026-04-18.md`)。当前系统因 Redis 未安装且限流策略为 fail-closed,**所有 API 请求返回 429**,系统完全不可用。本计划按优先级逐步修复,恢复系统可用性。
|
||||
|
||||
---
|
||||
|
||||
## Fix 1: 限流中间件改为 fail-open(P0-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 数据可操作
|
||||
189
plans/rosy-painting-whisper.md
Normal file
189
plans/rosy-painting-whisper.md
Normal 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 插件改进计划
|
||||
615
plans/skill-cosmic-pancake.md
Normal file
615
plans/skill-cosmic-pancake.md
Normal 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 | 流式解析 + 批量提交 + 文件大小限制 |
|
||||
| 打印模板安全 | 模板注入攻击 | 沙箱渲染 + 变量白名单 |
|
||||
| 插件市场审核成本 | 人工审核效率低 | 自动化扫描 + 人工抽查 + 社区举报 |
|
||||
488
plans/skill-wiki-jaunty-treehouse.md
Normal file
488
plans/skill-wiki-jaunty-treehouse.md
Normal 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 替换成功后才更新
|
||||
- 事务性:要么完全切换到新版本,要么保持旧版本
|
||||
|
||||
---
|
||||
|
||||
## 改动 4:Schema 演进(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 + ADD(JSONB 数据不需要改)
|
||||
|
||||
#### 改动文件
|
||||
|
||||
**`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 插件 WASM(v1.0.0 和 v2.0.0)
|
||||
2. 上传 v2.0.0 但故意让 WASM 二进制损坏
|
||||
3. 验证:旧版本 v1.0.0 仍在正常运行
|
||||
4. 上传正确的 v2.0.0
|
||||
5. 验证:成功切换到 v2.0.0
|
||||
|
||||
### 阶段 2:Schema 演进
|
||||
|
||||
1. 创建 CRM 插件 v1.0.0(含 customer 实体,3 个字段)
|
||||
2. 升级到 v1.1.0(customer 增加 2 个 filterable 字段 + 1 个新实体 contact)
|
||||
3. 验证:新字段可以过滤/排序,旧数据不受影响
|
||||
4. 在已有数据上验证新 Generated Column 的值正确填充
|
||||
|
||||
### 阶段 3:聚合查询
|
||||
|
||||
1. 创建测试数据(不同状态的订单,含 amount 字段)
|
||||
2. 调用聚合 API:group_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 逻辑,混合模型中复用
|
||||
357
plans/temporal-gathering-stream.md
Normal file
357
plans/temporal-gathering-stream.md
Normal 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 关键差距详解
|
||||
|
||||
**差距 1:ErpModule 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)。需要设计新的路由注入机制。
|
||||
|
||||
**差距 2:FromRef 状态模式与 WASM 插件的冲突**
|
||||
|
||||
当前每个模块有自己的 State 类型(`AuthState`、`ConfigState` 等),通过 `FromRef` 从 `AppState` 桥接。WASM 插件无法定义编译时的 `FromRef` 实现,需要运行时状态传递机制。
|
||||
|
||||
**差距 3:EventBus 缺少类型化订阅**
|
||||
|
||||
`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()` 返回 Ok,Host 端日志记录初始化成功 |
|
||||
| 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 |
|
||||
763
plans/zany-wobbling-shannon.md
Normal file
763
plans/zany-wobbling-shannon.md
Normal 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 channel(capacity 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)→ 调用前预填充 HostState,Host 方法直接返回缓存数据
|
||||
- 写操作(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 链路可用
|
||||
Reference in New Issue
Block a user