docs: 全局文档梳理归档 — 删除过期文件 + 归档 V1/早期设计 + wiki 数据校正 + CLAUDE.md 规则优化
**根目录清理:** - 删除 CLAUDE-1.md(ZCLAW 旧项目配置,HMS 已完全脱离) - 移动 DESIGN.md → docs/archive/(ERP 旧设计系统) - 删除 plans/ 98 个临时会话计划文件 **归档重组:** - V1 审计(12 文件)→ docs/archive/audits-v1/ - 早期 CRM/插件迭代设计(13 文件)→ docs/archive/superpowers-early/ - 已完成/已取代设计(28 文件)→ docs/archive/superpowers-completed/ - 早期讨论/测试报告 → docs/archive/discussions-early/ + test-reports-early/ - QA 重复文件清理(3 个旧版 result 文件) **wiki 数据校正:** - 迁移数 137→145,源文件 599→649,提交数 720→800+ - 小程序文件 124→163,Web 前端 297→332 - 后端测试 999→943(实际统计),权限码 75+→128 - 文档索引新增归档目录说明 **CLAUDE.md 规则优化:** - §2.5 闭环工作法:提交+文档+推送三合一 + wiki 更新触发条件 - §2.6 Feature DoD:新增文档一致性检查项 - §6 反模式:新增 wiki 更新滞后/推送不及时警告
This commit is contained in:
@@ -0,0 +1,985 @@
|
||||
# WASM 插件系统设计规格
|
||||
|
||||
> 日期:2026-04-13
|
||||
> 状态:审核通过 (v2 — 修复安全/多租户/迁移问题)
|
||||
> 关联:`docs/superpowers/specs/2026-04-10-erp-platform-base-design.md`
|
||||
> Review 历史:v1 首次审核 → 修复 C1-C4 关键问题 + I1-I5 重要问题 → v2 审核通过
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
ERP 平台底座 Phase 1-6 已全部完成,包含 auth、config、workflow、message 四大基础模块。
|
||||
当前系统是一个"模块化形状的单体"——模块以独立 crate 存在,但集成方式是编译时硬编码(main.rs 手动注册路由、合并迁移、启动后台任务)。
|
||||
|
||||
**核心矛盾:** Rust 的静态编译特性不支持运行时热插拔,但产品目标是"通用基座 + 行业插件"架构。
|
||||
|
||||
**本设计的目标:** 引入 WASM 运行时插件系统,使行业模块(进销存、生产、财务等)可以动态安装、启用、停用,无需修改基座代码。
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|--------|------|------|
|
||||
| 插件范围 | 仅行业模块动态化,基础模块保持 Rust 编译时 | 基础模块变更频率低、可靠性要求高,适合编译时保证 |
|
||||
| 插件技术 | WebAssembly (Wasmtime) | Rust 原生运行时,性能接近原生,沙箱安全 |
|
||||
| 数据库访问 | 宿主代理 API | 宿主自动注入 tenant_id、软删除、审计日志,插件无法绕过 |
|
||||
| 前端 UI | 配置驱动 | ERP 80% 页面是 CRUD,配置驱动覆盖大部分场景 |
|
||||
| 插件管理 | 内置插件商店 | 类似 WordPress 模型,管理后台上传 WASM 包 |
|
||||
| WASM 运行时 | Wasmtime | Bytecode Alliance 维护,Rust 原生,Cranelift JIT |
|
||||
|
||||
## 3. 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ erp-server │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ ModuleRegistry v2 │ │
|
||||
│ │ ┌─────────────────┐ ┌──────────────────────────┐│ │
|
||||
│ │ │ Native Modules │ │ Wasmtime Runtime ││ │
|
||||
│ │ │ ┌──────┐┌──────┐│ │ ┌──────┐┌──────┐┌──────┐││ │
|
||||
│ │ │ │ auth ││config ││ │ │进销存 ││ 生产 ││ 财务 │││ │
|
||||
│ │ │ ├──────┤├──────┤│ │ └──┬───┘└──┬───┘└──┬───┘││ │
|
||||
│ │ │ │workflow│msg ││ │ └────────┼────────┘ ││ │
|
||||
│ │ │ └──────┘└──────┘│ │ Host API Layer ││ │
|
||||
│ │ └─────────────────┘ └──────────────────────────┘│ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ ↕ EventBus │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ 统一 Axum Router │ │
|
||||
│ │ /api/v1/auth/* /api/v1/plugins/{id}/* │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
↕
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React SPA) │
|
||||
│ ┌──────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ 固定路由 │ │ 动态路由 (PluginRegistry Store) │ │
|
||||
│ │ /users /roles │ │ /inventory/* /production/* │ │
|
||||
│ └──────────────┘ └──────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐│
|
||||
│ │ PluginCRUDPage — 配置驱动的通用 CRUD 渲染引擎 ││
|
||||
│ └──────────────────────────────────────────────────────┘│
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 插件清单 (Plugin Manifest)
|
||||
|
||||
每个 WASM 插件包含一个 `plugin.toml` 清单文件:
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
id = "erp-inventory" # 全局唯一 ID,kebab-case
|
||||
name = "进销存管理" # 显示名称
|
||||
version = "1.0.0" # 语义化版本
|
||||
description = "商品/采购/销售/库存管理"
|
||||
author = "ERP Team"
|
||||
min_platform_version = "1.0.0" # 最低基座版本要求
|
||||
|
||||
[dependencies]
|
||||
modules = ["auth", "workflow"] # 依赖的基础模块 ID 列表
|
||||
|
||||
[permissions]
|
||||
database = true # 需要数据库访问
|
||||
events = true # 需要发布/订阅事件
|
||||
config = true # 需要读取系统配置
|
||||
files = false # 是否需要文件存储
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "inventory_item"
|
||||
fields = [
|
||||
{ name = "sku", type = "string", required = true, unique = true },
|
||||
{ name = "name", type = "string", required = true },
|
||||
{ name = "quantity", type = "integer", default = 0 },
|
||||
{ name = "unit", type = "string", default = "个" },
|
||||
{ name = "category_id", type = "uuid", nullable = true },
|
||||
{ name = "unit_price", type = "decimal", precision = 10, scale = 2 },
|
||||
]
|
||||
indexes = [["sku"], ["category_id"]]
|
||||
|
||||
[[schema.entities]]
|
||||
name = "purchase_order"
|
||||
fields = [
|
||||
{ name = "order_no", type = "string", required = true, unique = true },
|
||||
{ name = "supplier_id", type = "uuid" },
|
||||
{ name = "status", type = "string", default = "draft" },
|
||||
{ name = "total_amount", type = "decimal", precision = 12, scale = 2 },
|
||||
{ name = "order_date", type = "date" },
|
||||
]
|
||||
|
||||
[events]
|
||||
published = ["inventory.stock.low", "purchase_order.created", "purchase_order.approved"]
|
||||
subscribed = ["workflow.task.completed"]
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
name = "商品管理"
|
||||
path = "/inventory/items"
|
||||
entity = "inventory_item"
|
||||
type = "crud"
|
||||
icon = "ShoppingOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[ui.pages]]
|
||||
name = "采购管理"
|
||||
path = "/inventory/purchase"
|
||||
entity = "purchase_order"
|
||||
type = "crud"
|
||||
icon = "ShoppingCartOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[ui.pages]]
|
||||
name = "库存盘点"
|
||||
path = "/inventory/stocktaking"
|
||||
type = "custom"
|
||||
menu_group = "进销存"
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `schema.entities` 声明的表自动注入标准字段:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- `permissions` 控制插件可调用的宿主 API 范围(最小权限原则)
|
||||
- `ui.pages.type` 为 `crud` 时由通用渲染引擎自动生成页面,`custom` 时由插件处理渲染逻辑
|
||||
- 插件事件命名使用 `{plugin_id}.{entity}.{action}` 三段式,避免与基础模块的 `{module}.{action}` 二段式冲突
|
||||
- 动态创建的表使用 `plugin_{entity_name}` 格式,所有租户共享同一张表,通过 `tenant_id` 列实现行级隔离(与现有表模式一致)
|
||||
|
||||
## 5. 宿主 API (Host Functions)
|
||||
|
||||
WASM 插件通过宿主暴露的函数访问系统资源,这是插件与外部世界的唯一通道:
|
||||
|
||||
### 5.1 API 定义
|
||||
|
||||
```rust
|
||||
/// 宿主暴露给 WASM 插件的 API 接口
|
||||
/// 通过 Wasmtime Linker 注册为 host functions
|
||||
trait PluginHostApi {
|
||||
// === 数据库操作 ===
|
||||
|
||||
/// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段)
|
||||
fn db_insert(&mut self, entity: &str, data: &[u8]) -> Result<Vec<u8>>;
|
||||
|
||||
/// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤)
|
||||
fn db_query(&mut self, entity: &str, filter: &[u8], pagination: &[u8]) -> Result<Vec<u8]>;
|
||||
|
||||
/// 更新记录(自动检查 version 乐观锁)
|
||||
fn db_update(&mut self, entity: &str, id: &str, data: &[u8], version: i64) -> Result<Vec<u8]>;
|
||||
|
||||
/// 软删除记录
|
||||
fn db_delete(&mut self, entity: &str, id: &str) -> Result<()>;
|
||||
|
||||
/// 原始查询(仅允许 SELECT,自动注入 tenant_id 过滤)
|
||||
fn db_raw_query(&mut self, sql: &str, params: &[u8]) -> Result<Vec<u8]>;
|
||||
|
||||
// === 事件总线 ===
|
||||
|
||||
/// 发布领域事件
|
||||
fn event_publish(&mut self, event_type: &str, payload: &[u8]) -> Result<()>;
|
||||
|
||||
// === 配置 ===
|
||||
|
||||
/// 读取系统配置(插件作用域内)
|
||||
fn config_get(&mut self, key: &str) -> Result<Vec<u8]>;
|
||||
|
||||
// === 日志 ===
|
||||
|
||||
/// 写日志(自动关联 tenant_id + plugin_id)
|
||||
fn log_write(&mut self, level: &str, message: &str);
|
||||
|
||||
// === 用户/权限 ===
|
||||
|
||||
/// 获取当前用户信息
|
||||
fn current_user(&mut self) -> Result<Vec<u8]>;
|
||||
|
||||
/// 检查当前用户权限
|
||||
fn check_permission(&mut self, permission: &str) -> Result<bool>;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 安全边界
|
||||
|
||||
插件运行在 WASM 沙箱中,安全策略如下:
|
||||
|
||||
1. **权限校验** — 插件只能调用清单 `permissions` 中声明的宿主函数,未声明的调用在加载时被拦截
|
||||
2. **租户隔离** — 所有 `db_*` 操作自动注入 `tenant_id`,插件无法绕过多租户隔离。使用行级隔离(共享表 + tenant_id 过滤),与现有基础模块保持一致
|
||||
3. **资源限制** — 每个插件有独立的资源配额(内存上限、CPU 时间、API 调用频率)
|
||||
4. **审计记录** — 所有写操作自动记录审计日志
|
||||
5. **SQL 安全** — 不暴露原始 SQL 接口,`db_aggregate` 使用结构化查询对象,宿主层安全构建参数化 SQL
|
||||
6. **文件/网络隔离** — 插件不能直接访问文件系统或网络
|
||||
|
||||
### 5.3 数据流
|
||||
|
||||
```
|
||||
WASM 插件 宿主安全层 PostgreSQL
|
||||
┌──────────┐ ┌───────────────┐ ┌──────────┐
|
||||
│ 调用 │ ── Host Call ──→ │ 1. 权限校验 │ │ │
|
||||
│ db_insert │ │ 2. 注入标准字段 │ ── SQL ──→ │ INSERT │
|
||||
│ │ │ 3. 注入 tenant │ │ INTO │
|
||||
│ │ ←─ JSON 结果 ── │ 4. 写审计日志 │ │ │
|
||||
└──────────┘ └───────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## 6. 插件生命周期
|
||||
|
||||
### 6.1 状态机
|
||||
|
||||
```
|
||||
上传 WASM 包
|
||||
│
|
||||
▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ uploaded │───→│ installed │───→│ enabled │───→│ running │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
│ │
|
||||
│ ┌──────────┘
|
||||
│ ▼
|
||||
┌──────────┐
|
||||
│ disabled │←── 运行时错误自动停用
|
||||
└──────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│uninstalled│ ── 软删除插件记录,保留数据表和数据
|
||||
└──────────┘
|
||||
│
|
||||
▼ (可选,需管理员二次确认)
|
||||
┌──────────┐
|
||||
│ purged │ ── 真正删除数据表 + 数据导出备份
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### 6.2 各阶段操作
|
||||
|
||||
| 阶段 | 操作 |
|
||||
|------|------|
|
||||
| uploaded → installed | 校验清单格式、验证依赖模块存在、检查 min_platform_version |
|
||||
| installed → enabled | 根据 `schema.entities` 创建数据表(带 `plugin_` 前缀)、写入启用状态 |
|
||||
| enabled → running | 服务启动时:Wasmtime 实例化、注册 Host Functions、调用 `init()`、注册事件处理器、注册前端路由 |
|
||||
| running → disabled | 停止 WASM 实例、注销事件处理器、注销路由 |
|
||||
| disabled → uninstalled | 软删除插件记录(设置 `deleted_at`),**保留数据表和数据不变**,清理事件订阅记录 |
|
||||
| uninstalled → purged | 数据导出备份后,删除 `plugin_*` 数据表。**需要管理员二次确认 + 数据导出完成** |
|
||||
|
||||
### 6.3 启动加载流程
|
||||
|
||||
```rust
|
||||
async fn load_plugins(db: &DatabaseConnection) -> Vec<LoadedPlugin> {
|
||||
// 1. 查询所有 enabled 状态的插件
|
||||
let plugins = Plugin::find()
|
||||
.filter(status.eq("enabled"))
|
||||
.filter(deleted_at.is_null())
|
||||
.all(db).await?;
|
||||
|
||||
let mut loaded = Vec::new();
|
||||
for plugin in plugins {
|
||||
// 2. 初始化 Wasmtime Engine(复用全局 Engine)
|
||||
let module = Module::from_binary(&engine, &plugin.wasm_binary)?;
|
||||
|
||||
// 3. 创建 Linker,根据 permissions 注册对应的 Host Functions
|
||||
let mut linker = Linker::new(&engine);
|
||||
register_host_functions(&mut linker, &plugin.permissions)?;
|
||||
|
||||
// 4. 实例化
|
||||
let instance = linker.instantiate_async(&mut store, &module).await?;
|
||||
|
||||
// 5. 调用插件的 init() 入口函数
|
||||
if let Ok(init) = instance.get_typed_func::<(), ()>(&mut store, "init") {
|
||||
init.call_async(&mut store, ()).await?;
|
||||
}
|
||||
|
||||
// 6. 注册事件处理器
|
||||
for sub in &plugin.manifest.events.subscribed {
|
||||
event_bus.subscribe_filtered(sub, plugin_handler(plugin.id, instance.clone()));
|
||||
}
|
||||
|
||||
loaded.push(LoadedPlugin { plugin, instance, store });
|
||||
}
|
||||
|
||||
// 7. 依赖排序验证
|
||||
validate_dependencies(&loaded)?;
|
||||
|
||||
Ok(loaded)
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 数据库 Schema
|
||||
|
||||
### 7.1 新增表
|
||||
|
||||
```sql
|
||||
-- 插件注册表
|
||||
CREATE TABLE plugins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
plugin_id VARCHAR(100) NOT NULL, -- 清单中的唯一 ID
|
||||
name VARCHAR(200) NOT NULL,
|
||||
plugin_version VARCHAR(20) NOT NULL, -- 插件语义化版本(避免与乐观锁 version 混淆)
|
||||
description TEXT,
|
||||
manifest JSONB NOT NULL, -- 完整清单 JSON
|
||||
wasm_binary BYTEA NOT NULL, -- 编译后的 WASM 二进制
|
||||
status VARCHAR(20) DEFAULT 'installed',
|
||||
-- uploaded / installed / enabled / disabled / error
|
||||
permissions JSONB NOT NULL,
|
||||
error_message TEXT,
|
||||
schema_version INTEGER DEFAULT 1, -- 插件数据 schema 版本
|
||||
config JSONB DEFAULT '{}', -- 插件配置
|
||||
-- 标准字段
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID,
|
||||
updated_by UUID,
|
||||
deleted_at TIMESTAMPTZ, -- 软删除(卸载不删数据)
|
||||
row_version INTEGER NOT NULL DEFAULT 1, -- 乐观锁版本
|
||||
UNIQUE(tenant_id, plugin_id)
|
||||
);
|
||||
|
||||
-- 插件 schema 版本跟踪(用于动态表的版本管理)
|
||||
CREATE TABLE plugin_schema_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id VARCHAR(100) NOT NULL, -- 全局唯一的插件 ID
|
||||
entity_name VARCHAR(100) NOT NULL, -- 实体名
|
||||
schema_version INTEGER NOT NULL DEFAULT 1, -- 当前 schema 版本
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(plugin_id, entity_name)
|
||||
);
|
||||
|
||||
-- 插件事件订阅记录
|
||||
CREATE TABLE plugin_event_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
plugin_id VARCHAR(100) NOT NULL,
|
||||
event_type VARCHAR(200) NOT NULL,
|
||||
handler_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 7.2 动态数据表
|
||||
|
||||
插件安装时根据 `manifest.schema.entities` 自动创建数据表:
|
||||
|
||||
- 表名格式:`plugin_{entity_name}`
|
||||
- **行级隔离模式**:所有租户共享同一张 `plugin_*` 表,通过 `tenant_id` 列过滤实现隔离(与现有基础模块的表保持一致)
|
||||
- 首次创建表时使用 `IF NOT EXISTS`(幂等),后续租户安装同一插件时复用已有表
|
||||
- 自动包含标准字段:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- 索引自动创建:主键 + `tenant_id`(必选)+ 清单中声明的自定义索引
|
||||
- **注意**:此方式绕过 SeaORM Migration 系统,属于合理偏差——插件是运行时动态加载的,其 schema 无法在编译时通过静态迁移管理。宿主维护 `plugin_schema_versions` 表跟踪每个插件的 schema 版本
|
||||
|
||||
## 8. 配置驱动 UI
|
||||
|
||||
### 8.1 前端架构
|
||||
|
||||
```
|
||||
插件 manifest.ui.pages
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ PluginStore │ Zustand Store,从 /api/v1/plugins/:id/pages 加载
|
||||
│ (前端插件注册表) │ 缓存所有已启用插件的页面配置
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ DynamicRouter │ React Router,根据 PluginStore 自动生成路由
|
||||
│ (动态路由层) │ 懒加载 PluginCRUDPage / PluginDashboard
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ PluginCRUDPage │ 通用 CRUD 页面组件
|
||||
│ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ SearchBar │ │ 根据 filters 配置自动生成搜索条件
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ DataTable │ │ 根据 columns 配置渲染 Ant Design Table
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ FormDialog │ │ 根据 form 配置渲染新建/编辑表单
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ ActionBar │ │ 根据 actions 配置渲染操作按钮
|
||||
│ └─────────────┘ │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 页面配置类型
|
||||
|
||||
```typescript
|
||||
interface PluginPageConfig {
|
||||
name: string;
|
||||
path: string;
|
||||
entity: string;
|
||||
type: "crud" | "dashboard" | "custom";
|
||||
icon?: string;
|
||||
menu_group: string;
|
||||
|
||||
// CRUD 配置(可选,不提供时从 schema.entities 自动推导)
|
||||
// columns 未指定时:从 entity 的 fields 生成,type=select 需显式指定 options
|
||||
// form 未指定时:从 entity 的 fields 生成表单,required 字段为必填
|
||||
columns?: ColumnDef[];
|
||||
filters?: FilterDef[];
|
||||
actions?: ActionDef[];
|
||||
form?: FormDef;
|
||||
}
|
||||
|
||||
interface ColumnDef {
|
||||
field: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "date" | "datetime" | "select"
|
||||
| "multiselect" | "currency" | "status" | "link";
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
hidden?: boolean;
|
||||
options?: { label: string; value: string; color?: string }[];
|
||||
}
|
||||
|
||||
interface FormDef {
|
||||
groups?: FormGroup[];
|
||||
fields: FormField[];
|
||||
rules?: ValidationRule[];
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 动态菜单生成
|
||||
|
||||
前端侧边栏从 PluginStore 动态生成菜单项:
|
||||
|
||||
- 基础模块菜单固定(用户、权限、组织、工作流、消息、设置)
|
||||
- 插件菜单按 `menu_group` 分组,动态追加到侧边栏
|
||||
- 菜单数据来自 `/api/v1/plugins/installed` API,启动时加载
|
||||
|
||||
### 8.4 插件 API 路由
|
||||
|
||||
插件的 CRUD API 由宿主自动生成:
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{plugin_id}/{entity} # 列表查询
|
||||
GET /api/v1/plugins/{plugin_id}/{entity}/{id} # 详情
|
||||
POST /api/v1/plugins/{plugin_id}/{entity} # 新建
|
||||
PUT /api/v1/plugins/{plugin_id}/{entity}/{id} # 更新
|
||||
DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} # 删除
|
||||
```
|
||||
|
||||
宿主自动注入 tenant_id、处理分页、乐观锁、软删除。
|
||||
|
||||
### 8.5 自定义页面
|
||||
|
||||
`type: "custom"` 的页面需要额外的渲染指令:
|
||||
|
||||
- 插件 WASM 可以导出 `render_page` 函数,返回 UI 指令 JSON
|
||||
- 宿主前端解析指令并渲染(支持:条件显示、自定义操作、复杂布局)
|
||||
- 复杂交互(如库存盘点)通过事件驱动:前端发送 action → 后端 WASM 处理 → 返回新的 UI 状态
|
||||
|
||||
## 9. 升级后的模块注册系统
|
||||
|
||||
### 9.1 ErpModule trait v2
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ErpModule: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
|
||||
fn dependencies(&self) -> Vec<&str> { vec![] }
|
||||
fn module_type(&self) -> ModuleType;
|
||||
|
||||
// 生命周期
|
||||
async fn on_startup(&self, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> AppResult<ModuleHealth> {
|
||||
Ok(ModuleHealth { status: "ok".into(), details: None })
|
||||
}
|
||||
|
||||
// 路由
|
||||
fn public_routes(&self) -> Option<Router> { None }
|
||||
fn protected_routes(&self) -> Option<Router> { None }
|
||||
|
||||
// 数据库
|
||||
fn migrations(&self) -> Vec<Box<dyn MigrationTrait>> { vec![] }
|
||||
|
||||
// 事件
|
||||
fn register_event_handlers(&self, bus: &EventBus) {}
|
||||
|
||||
// 租户
|
||||
async fn on_tenant_created(&self, tenant_id: Uuid, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_tenant_deleted(&self, tenant_id: Uuid, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
|
||||
// 配置
|
||||
fn config_schema(&self) -> Option<serde_json::Value> { None }
|
||||
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
pub enum ModuleType { Native, Wasm }
|
||||
|
||||
pub struct ModuleHealth {
|
||||
pub status: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ModuleContext {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub config: Arc<AppConfig>,
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 ModuleRegistry v2
|
||||
|
||||
```rust
|
||||
pub struct ModuleRegistry {
|
||||
modules: Arc<Vec<Arc<dyn ErpModule>>>,
|
||||
wasm_runtime: Arc<WasmPluginRuntime>,
|
||||
index: Arc<HashMap<String, usize>>,
|
||||
}
|
||||
|
||||
impl ModuleRegistry {
|
||||
pub fn new() -> Self;
|
||||
|
||||
// 注册 Rust 原生模块
|
||||
pub fn register(self, module: impl ErpModule + 'static) -> Self;
|
||||
|
||||
// 从数据库加载 WASM 插件
|
||||
pub async fn load_wasm_plugins(&mut self, db: &DatabaseConnection) -> AppResult<()>;
|
||||
|
||||
// 按依赖顺序启动所有模块
|
||||
pub async fn startup_all(&self, ctx: &ModuleContext) -> AppResult<()>;
|
||||
|
||||
// 聚合健康状态
|
||||
pub async fn health_check_all(&self) -> HashMap<String, ModuleHealth>;
|
||||
|
||||
// 自动收集所有路由
|
||||
pub fn build_routes(&self) -> (Router, Router);
|
||||
|
||||
// 自动收集所有迁移
|
||||
pub fn collect_migrations(&self) -> Vec<Box<dyn MigrationTrait>>;
|
||||
|
||||
// 拓扑排序(基于 dependencies)
|
||||
fn topological_sort(&self) -> AppResult<Vec<Arc<dyn ErpModule>>>;
|
||||
|
||||
// 按 ID 查找模块
|
||||
pub fn get_module(&self, id: &str) -> Option<&Arc<dyn ErpModule>>;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 升级后的 main.rs
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 初始化 DB、Config、EventBus ...
|
||||
|
||||
// 1. 注册 Rust 原生模块
|
||||
let mut registry = ModuleRegistry::new()
|
||||
.register(AuthModule::new())
|
||||
.register(ConfigModule::new())
|
||||
.register(WorkflowModule::new())
|
||||
.register(MessageModule::new());
|
||||
|
||||
// 2. 从数据库加载 WASM 插件
|
||||
registry.load_wasm_plugins(&db).await?;
|
||||
|
||||
// 3. 依赖排序 + 启动所有模块
|
||||
let ctx = ModuleContext { db: db.clone(), event_bus: event_bus.clone(), config: config.clone() };
|
||||
registry.startup_all(&ctx).await?;
|
||||
|
||||
// 4. 自动收集路由(无需手动 merge)
|
||||
let (public, protected) = registry.build_routes();
|
||||
|
||||
// 5. 构建 Axum 服务
|
||||
let app = Router::new()
|
||||
.nest("/api/v1", public.merge(protected))
|
||||
.with_state(app_state);
|
||||
|
||||
// 启动服务 ...
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 插件开发体验
|
||||
|
||||
### 10.1 插件项目结构
|
||||
|
||||
```
|
||||
erp-plugin-inventory/
|
||||
├── Cargo.toml # crate 类型为 cdylib (WASM)
|
||||
├── plugin.toml # 插件清单
|
||||
└── src/
|
||||
└── lib.rs # 插件入口
|
||||
```
|
||||
|
||||
### 10.2 插件 Cargo.toml
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-inventory"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.24" # WIT 接口绑定生成
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
### 10.3 插件代码示例
|
||||
|
||||
```rust
|
||||
use wit_bindgen::generate::Guest;
|
||||
|
||||
// 自动生成宿主 API 绑定
|
||||
export!(Plugin);
|
||||
|
||||
struct Plugin;
|
||||
|
||||
impl Guest for Plugin {
|
||||
fn init() -> Result<(), String> {
|
||||
host::log_write("info", "进销存插件初始化完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||||
// 初始化默认商品分类等
|
||||
host::db_insert("inventory_category", br#"{"name": "默认分类"}"#)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||||
match event_type.as_str() {
|
||||
"workflow.task.completed" => {
|
||||
// 采购审批通过,更新采购单状态
|
||||
let data: serde_json::Value = serde_json::from_slice(&payload)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let order_id = data["business_id"].as_str().unwrap();
|
||||
host::db_update("purchase_order", order_id,
|
||||
br#"{"status": "approved"}"#, 1)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 构建与发布
|
||||
|
||||
```bash
|
||||
# 编译为 WASM
|
||||
cargo build --target wasm32-unknown-unknown --release
|
||||
|
||||
# 打包(WASM 二进制 + 清单文件)
|
||||
erp-plugin pack ./target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm \
|
||||
--manifest ./plugin.toml \
|
||||
--output ./erp-inventory-1.0.0.erp-plugin
|
||||
|
||||
# 上传到平台(通过管理后台或 API)
|
||||
curl -X POST /api/v1/admin/plugins/upload \
|
||||
-F "plugin=@./erp-inventory-1.0.0.erp-plugin"
|
||||
```
|
||||
|
||||
## 11. 管理后台 API
|
||||
|
||||
### 11.1 插件管理接口
|
||||
|
||||
```
|
||||
POST /api/v1/admin/plugins/upload # 上传插件包
|
||||
GET /api/v1/admin/plugins # 列出所有插件
|
||||
GET /api/v1/admin/plugins/{plugin_id} # 插件详情
|
||||
POST /api/v1/admin/plugins/{plugin_id}/enable # 启用插件
|
||||
POST /api/v1/admin/plugins/{plugin_id}/disable # 停用插件
|
||||
DELETE /api/v1/admin/plugins/{plugin_id} # 卸载插件
|
||||
GET /api/v1/admin/plugins/{plugin_id}/health # 插件健康检查
|
||||
PUT /api/v1/admin/plugins/{plugin_id}/config # 更新插件配置
|
||||
POST /api/v1/admin/plugins/{plugin_id}/upgrade # 升级插件版本
|
||||
```
|
||||
|
||||
### 11.2 插件数据接口(自动生成)
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{plugin_id}/{entity} # 列表查询
|
||||
GET /api/v1/plugins/{plugin_id}/{entity}/{id} # 详情
|
||||
POST /api/v1/plugins/{plugin_id}/{entity} # 新建
|
||||
PUT /api/v1/plugins/{plugin_id}/{entity}/{id} # 更新
|
||||
DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} # 删除
|
||||
```
|
||||
|
||||
## 12. 实施路径
|
||||
|
||||
### Phase 7: 插件系统核心
|
||||
|
||||
1. **引入 Wasmtime 依赖**,创建 `erp-plugin-runtime` crate
|
||||
2. **定义 WIT 接口文件**,描述宿主-插件合约
|
||||
3. **实现 Host API 层** — db_insert/query/update/delete、event_publish、config_get 等
|
||||
4. **实现插件加载器** — 从数据库读取 WASM 二进制、实例化、注册路由
|
||||
5. **升级 ErpModule trait** — 添加 lifecycle hooks、routes、migrations 方法
|
||||
6. **升级 ModuleRegistry** — 拓扑排序、自动路由收集、WASM 插件注册
|
||||
7. **插件管理 API** — 上传、启用、停用、卸载
|
||||
8. **插件数据库表** — plugins、plugin_event_subscriptions + 动态建表逻辑
|
||||
|
||||
### Phase 8: 前端配置驱动 UI
|
||||
|
||||
1. **PluginStore** (Zustand) — 管理已安装插件的页面配置
|
||||
2. **DynamicRouter** — 根据 PluginStore 自动生成 React Router 路由
|
||||
3. **PluginCRUDPage** — 通用 CRUD 渲染引擎(表格 + 搜索 + 表单 + 操作)
|
||||
4. **动态菜单** — 从 PluginStore 生成侧边栏菜单
|
||||
5. **插件管理页面** — 上传、启用/停用、配置的管理后台
|
||||
|
||||
### Phase 9: 第一个行业插件(进销存)
|
||||
|
||||
1. 创建 `erp-plugin-inventory` 作为参考实现
|
||||
2. 实现商品、采购、库存管理的核心业务逻辑
|
||||
3. 配置驱动页面覆盖 80% 的 CRUD 场景
|
||||
4. 验证端到端流程:安装 → 启用 → 使用 → 停用 → 卸载
|
||||
|
||||
## 13. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|----------|
|
||||
| WASM 插件性能不足 | 低 | 高 | 性能基准测试,关键路径保留 Rust 原生 |
|
||||
| 插件安全问题 | 中 | 高 | 沙箱隔离 + 最小权限 + 审计日志 |
|
||||
| 配置驱动 UI 覆盖不足 | 中 | 中 | 保留 custom 页面类型作为兜底 |
|
||||
| 插件间依赖冲突 | 中 | 中 | 拓扑排序 + 版本约束 + 冲突检测 |
|
||||
| Wasmtime 版本兼容性 | 低 | 中 | 锁定 Wasmtime 大版本,CI 验证 |
|
||||
|
||||
## 附录 A: ErpModule Trait 迁移策略
|
||||
|
||||
### A.1 向后兼容原则
|
||||
|
||||
`ErpModule` trait v2 的所有新增方法均提供**默认实现(no-op)**,确保现有四个模块(AuthModule、ConfigModule、WorkflowModule、MessageModule)无需修改即可编译通过。
|
||||
|
||||
### A.2 迁移清单
|
||||
|
||||
| 现有方法 | v2 变化 | 迁移操作 |
|
||||
|----------|---------|----------|
|
||||
| `fn name(&self) -> &str` | 保留不变,新增 `fn id()` 返回相同值 | 在各模块 impl 中添加 `fn id()` |
|
||||
| `fn version()` | 保留不变 | 无需改动 |
|
||||
| `fn dependencies()` | 保留不变 | 无需改动 |
|
||||
| `fn register_event_handlers()` | 签名不变 | 无需改动 |
|
||||
| `fn on_tenant_created(tenant_id)` | 签名变为 `on_tenant_created(tenant_id, ctx)` | 更新签名,添加 `ctx` 参数 |
|
||||
| `fn on_tenant_deleted(tenant_id)` | 签名变为 `on_tenant_deleted(tenant_id, ctx)` | 更新签名,添加 `ctx` 参数 |
|
||||
| `fn as_any()` | 保留不变 | 无需改动 |
|
||||
| (新增)`fn module_type()` | 默认返回 `ModuleType::Native` | 无需改动 |
|
||||
| (新增)`fn on_startup()` | 默认 no-op | 可选实现 |
|
||||
| (新增)`fn on_shutdown()` | 默认 no-op | 可选实现 |
|
||||
| (新增)`fn health_check()` | 默认返回 ok | 可选实现 |
|
||||
| (新增)`fn public_routes()` | 默认 None | 将现有关联函数迁移到此方法 |
|
||||
| (新增)`fn protected_routes()` | 默认 None | 将现有关联函数迁移到此方法 |
|
||||
| (新增)`fn migrations()` | 默认空 vec | 可选实现 |
|
||||
| (新增)`fn config_schema()` | 默认 None | 可选实现 |
|
||||
|
||||
### A.3 迁移后的 main.rs 变化
|
||||
|
||||
迁移后,main.rs 从手动路由合并变为自动收集:
|
||||
|
||||
```rust
|
||||
// 迁移前(手动)
|
||||
let protected_routes = erp_auth::AuthModule::protected_routes()
|
||||
.merge(erp_config::ConfigModule::protected_routes())
|
||||
.merge(erp_workflow::WorkflowModule::protected_routes())
|
||||
.merge(erp_message::MessageModule::protected_routes());
|
||||
|
||||
// 迁移后(自动)
|
||||
let (public, protected) = registry.build_routes();
|
||||
```
|
||||
|
||||
## 附录 B: EventBus 类型化订阅扩展
|
||||
|
||||
### B.1 现有 EventBus 扩展
|
||||
|
||||
现有的 `EventBus`(`erp-core/src/events.rs`)只有 `subscribe()` 方法返回全部事件的 `Receiver`。需要添加类型化过滤订阅:
|
||||
|
||||
```rust
|
||||
impl EventBus {
|
||||
/// 订阅特定事件类型
|
||||
/// 内部使用 mpmc 通道,为每个事件类型维护独立的分发器
|
||||
pub fn subscribe_filtered(
|
||||
&self,
|
||||
event_type: &str,
|
||||
handler: Box<dyn Fn(DomainEvent) + Send + Sync>,
|
||||
) -> SubscriptionHandle {
|
||||
// 在内部 HashMap<String, Vec<Handler>> 中注册
|
||||
// publish() 时根据 event_type 分发到匹配的 handler
|
||||
}
|
||||
|
||||
/// 取消订阅(用于插件停用时清理)
|
||||
pub fn unsubscribe(&self, handle: SubscriptionHandle) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### B.2 插件事件处理器包装
|
||||
|
||||
```rust
|
||||
struct PluginEventHandler {
|
||||
plugin_id: String,
|
||||
handler_fn: Box<dyn Fn(DomainEvent) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl PluginEventHandler {
|
||||
fn handle(&self, event: DomainEvent) {
|
||||
// 捕获 panic,防止插件崩溃影响宿主
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
(self.handler_fn)(event)
|
||||
});
|
||||
if let Err(_) = result {
|
||||
tracing::error!("插件 {} 事件处理器崩溃", self.plugin_id);
|
||||
// 通知 PluginManager 标记插件为 error 状态
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 C: 管理后台 API 权限控制
|
||||
|
||||
### C.1 权限模型
|
||||
|
||||
| API 端点 | 所需权限 | 角色范围 |
|
||||
|----------|---------|----------|
|
||||
| `POST /admin/plugins/upload` | `plugin:admin` | 仅平台超级管理员 |
|
||||
| `POST /admin/plugins/{id}/enable` | `plugin:manage` | 平台管理员或租户管理员(仅限自己租户的插件) |
|
||||
| `POST /admin/plugins/{id}/disable` | `plugin:manage` | 平台管理员或租户管理员 |
|
||||
| `DELETE /admin/plugins/{id}` | `plugin:manage` | 租户管理员(软删除) |
|
||||
| `DELETE /admin/plugins/{id}/purge` | `plugin:admin` | 仅平台超级管理员 |
|
||||
| `GET /admin/plugins` | `plugin:view` | 租户管理员(仅看到自己租户的插件) |
|
||||
| `PUT /admin/plugins/{id}/config` | `plugin:configure` | 租户管理员 |
|
||||
| `GET /admin/plugins/{id}/health` | `plugin:view` | 租户管理员 |
|
||||
|
||||
### C.2 租户隔离
|
||||
|
||||
- 插件管理 API 自动注入 `tenant_id` 过滤(从 JWT 中提取)
|
||||
- 平台超级管理员可以通过 `/admin/platform/plugins` 查看所有租户的插件
|
||||
- 租户管理员只能管理自己租户安装的插件
|
||||
- 插件上传为平台级操作(所有租户共享同一个 WASM 二进制),但启用/配置为租户级操作
|
||||
|
||||
## 附录 D: WIT 接口定义
|
||||
|
||||
### D.1 插件接口 (`plugin.wit`)
|
||||
|
||||
```wit
|
||||
package erp:plugin;
|
||||
|
||||
interface host {
|
||||
/// 数据库操作
|
||||
db-insert: func(entity: string, data: list<u8>) -> 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-aggregate: func(entity: string, query: list<u8>) -> 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 {
|
||||
/// 插件初始化(加载时调用一次)
|
||||
init: func() -> result<_, string>;
|
||||
|
||||
/// 租户创建时调用
|
||||
on-tenant-created: func(tenant-id: string) -> result<_, string>;
|
||||
|
||||
/// 处理订阅的事件
|
||||
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
|
||||
/// 自定义页面渲染(仅 type=custom 页面)
|
||||
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;
|
||||
export plugin;
|
||||
}
|
||||
```
|
||||
|
||||
### D.2 使用方式
|
||||
|
||||
插件开发者使用 `wit-bindgen` 生成绑定代码:
|
||||
|
||||
```bash
|
||||
# 生成 Rust 插件绑定
|
||||
wit-bindgen rust ./plugin.wit --out-dir ./src/generated
|
||||
```
|
||||
|
||||
宿主使用 `wasmtime` 的 `bindgen!` 宏生成调用端代码:
|
||||
|
||||
```rust
|
||||
// 在 erp-plugin-runtime crate 中
|
||||
wasmtime::component::bindgen!({
|
||||
path: "./plugin.wit",
|
||||
world: "plugin-world",
|
||||
async: true,
|
||||
});
|
||||
```
|
||||
|
||||
## 附录 E: 插件崩溃恢复策略
|
||||
|
||||
### E.1 崩溃检测与恢复
|
||||
|
||||
| 场景 | 检测方式 | 恢复策略 |
|
||||
|------|---------|----------|
|
||||
| WASM 执行 panic | `catch_unwind` 捕获 | 记录错误日志,该请求返回 500,插件继续运行 |
|
||||
| 插件 init() 失败 | 返回 Err | 标记插件为 `error` 状态,不加载 |
|
||||
| 事件处理器崩溃 | `catch_unwind` 捕获 | 记录错误日志,事件丢弃(不重试) |
|
||||
| 连续崩溃(>5次/分钟) | 计数器检测 | 自动停用插件,标记 `error`,通知管理员 |
|
||||
| 服务重启 | 启动流程 | 重新加载所有 `enabled` 状态的插件 |
|
||||
|
||||
### E.2 僵尸状态处理
|
||||
|
||||
插件在数据库中为 `enabled` 但实际未运行的情况:
|
||||
|
||||
1. 服务启动时,所有 `enabled` 插件尝试加载
|
||||
2. 加载失败的插件自动标记为 `error`,`error_message` 记录原因
|
||||
3. 管理后台显示 `error` 状态的插件,提供"重试"按钮
|
||||
4. 重试成功后恢复为 `enabled`,重试失败保持 `error`
|
||||
|
||||
### E.3 插件健康检查
|
||||
|
||||
```rust
|
||||
/// 定期健康检查(每 60 秒)
|
||||
async fn health_check_loop(registry: &ModuleRegistry) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let results = registry.health_check_all().await;
|
||||
for (id, health) in results {
|
||||
if health.status != "ok" {
|
||||
tracing::warn!("模块 {} 健康检查异常: {:?}", id, health.details);
|
||||
// 通知管理后台
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 F: Crate 依赖图更新
|
||||
|
||||
```
|
||||
erp-core (无业务依赖)
|
||||
erp-common (无业务依赖)
|
||||
↑
|
||||
erp-auth (→ core)
|
||||
erp-config (→ core)
|
||||
erp-workflow (→ core)
|
||||
erp-message (→ core)
|
||||
erp-plugin-runtime (→ core, wasmtime) ← 新增
|
||||
↑
|
||||
erp-server (→ 所有 crate,组装入口)
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `erp-plugin-runtime` 依赖 `erp-core`(使用 EventBus、ErpModule trait、AppError)
|
||||
- `erp-plugin-runtime` 依赖 `wasmtime`(WASM 运行时)
|
||||
- `erp-plugin-runtime` 不依赖任何业务 crate(auth/config/workflow/message)
|
||||
- `erp-server` 在组装时引入 `erp-plugin-runtime`
|
||||
@@ -0,0 +1,702 @@
|
||||
# WASM 插件系统实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 ERP 平台引入 WASM 运行时插件系统,使行业模块可动态安装/启用/停用。
|
||||
|
||||
**Architecture:** 基础模块(auth/config/workflow/message)保持 Rust 编译时,新增 `erp-plugin-runtime` crate 封装 Wasmtime 运行时。插件通过宿主代理 API 访问数据库和事件总线,前端使用配置驱动 UI 渲染引擎自动生成 CRUD 页面。
|
||||
|
||||
**Tech Stack:** Rust + Wasmtime 27+ / WIT (wit-bindgen 0.24+) / SeaORM / Axum 0.8 / React 19 + Ant Design 6 + Zustand 5
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### 新建文件
|
||||
|
||||
```
|
||||
crates/erp-plugin-runtime/
|
||||
├── Cargo.toml
|
||||
├── wit/
|
||||
│ └── plugin.wit # WIT 接口定义
|
||||
└── src/
|
||||
├── lib.rs # crate 入口
|
||||
├── manifest.rs # plugin.toml 解析
|
||||
├── engine.rs # Wasmtime 引擎封装
|
||||
├── host_api.rs # 宿主 API(db/event/config/log)
|
||||
├── loader.rs # 插件加载器
|
||||
├── schema.rs # 动态建表逻辑
|
||||
├── error.rs # 插件错误类型
|
||||
└── wasm_module.rs # ErpModule trait 的 WASM 适配器
|
||||
|
||||
crates/erp-server/migration/src/
|
||||
└── m20260413_000032_create_plugins_table.rs # plugins + plugin_schema_versions 表
|
||||
|
||||
crates/erp-server/src/
|
||||
└── handlers/
|
||||
└── plugin.rs # 插件管理 + 数据 CRUD handler
|
||||
|
||||
apps/web/src/
|
||||
├── api/
|
||||
│ └── plugins.ts # 插件 API service
|
||||
├── stores/
|
||||
│ └── plugin.ts # PluginStore (Zustand)
|
||||
├── pages/
|
||||
│ ├── PluginAdmin.tsx # 插件管理页面
|
||||
│ └── PluginCRUDPage.tsx # 通用 CRUD 渲染引擎
|
||||
└── components/
|
||||
└── DynamicMenu.tsx # 动态菜单组件
|
||||
```
|
||||
|
||||
### 修改文件
|
||||
|
||||
```
|
||||
Cargo.toml # 添加 erp-plugin-runtime workspace member
|
||||
crates/erp-core/src/module.rs # 升级 ErpModule trait v2
|
||||
crates/erp-core/src/events.rs # 添加 subscribe_filtered
|
||||
crates/erp-core/src/lib.rs # 导出新类型
|
||||
crates/erp-auth/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-config/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-workflow/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-message/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-server/src/main.rs # 使用新注册系统 + 加载 WASM 插件
|
||||
crates/erp-server/src/state.rs # 添加 PluginState
|
||||
crates/erp-server/migration/src/lib.rs # 注册新迁移
|
||||
apps/web/src/App.tsx # 添加动态路由 + PluginAdmin 路由
|
||||
apps/web/src/layouts/MainLayout.tsx # 使用 DynamicMenu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: ErpModule Trait v2 迁移 + EventBus 扩展
|
||||
|
||||
### Task 1: 升级 ErpModule trait
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-core/src/module.rs`
|
||||
- Modify: `crates/erp-core/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 升级 ErpModule trait — 添加新方法(全部有默认实现)**
|
||||
|
||||
在 `crates/erp-core/src/module.rs` 中,保留所有现有方法签名不变,追加新方法:
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
|
||||
// 新增类型
|
||||
pub enum ModuleType {
|
||||
Native,
|
||||
Wasm,
|
||||
}
|
||||
|
||||
pub struct ModuleHealth {
|
||||
pub status: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ModuleContext {
|
||||
pub db: sea_orm::DatabaseConnection,
|
||||
pub event_bus: crate::events::EventBus,
|
||||
pub config: Arc<serde_json::Value>,
|
||||
}
|
||||
|
||||
// 在 ErpModule trait 中追加(不改现有方法):
|
||||
fn id(&self) -> &str { self.name() } // 默认等于 name
|
||||
fn module_type(&self) -> ModuleType { ModuleType::Native }
|
||||
async fn on_startup(&self, _ctx: &ModuleContext) -> crate::error::AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> crate::error::AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> crate::error::AppResult<ModuleHealth> {
|
||||
Ok(ModuleHealth { status: "ok".into(), details: None })
|
||||
}
|
||||
fn public_routes(&self) -> Option<axum::Router> { None } // 需要 axum 依赖
|
||||
fn protected_routes(&self) -> Option<axum::Router> { None }
|
||||
fn migrations(&self) -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> { vec![] }
|
||||
fn config_schema(&self) -> Option<serde_json::Value> { None }
|
||||
```
|
||||
|
||||
> **注意:** `on_tenant_created/deleted` 的签名暂不改动(加 ctx 参数是破坏性变更),在 Task 2 中单独处理。
|
||||
|
||||
- [ ] **Step 2: 升级 ModuleRegistry — 添加索引 + 拓扑排序 + build_routes**
|
||||
|
||||
在同一个文件中扩展 `ModuleRegistry`:
|
||||
|
||||
```rust
|
||||
impl ModuleRegistry {
|
||||
pub fn get_module(&self, id: &str) -> Option<&Arc<dyn ErpModule>> { ... }
|
||||
pub fn build_routes(&self) -> (axum::Router, axum::Router) {
|
||||
// 遍历 modules,收集 public_routes + protected_routes
|
||||
}
|
||||
fn topological_sort(&self) -> crate::error::AppResult<Vec<Arc<dyn ErpModule>>> {
|
||||
// 基于 dependencies() 的 Kahn 算法拓扑排序
|
||||
}
|
||||
pub async fn startup_all(&self, ctx: &ModuleContext) -> crate::error::AppResult<()> {
|
||||
// 按拓扑顺序调用 on_startup
|
||||
}
|
||||
pub async fn health_check_all(&self) -> HashMap<String, ModuleHealth> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 更新 lib.rs 导出**
|
||||
|
||||
`crates/erp-core/src/lib.rs` 追加:
|
||||
```rust
|
||||
pub use module::{ModuleType, ModuleHealth, ModuleContext};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 更新 erp-core Cargo.toml 添加 axum 依赖**
|
||||
|
||||
`crates/erp-core/Cargo.toml` 的 `[dependencies]` 添加:
|
||||
```toml
|
||||
axum = { workspace = true }
|
||||
sea-orm-migration = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行 `cargo check --workspace` 确保现有模块编译通过(所有新方法有默认实现)**
|
||||
|
||||
- [ ] **Step 6: 迁移四个现有模块的 routes**
|
||||
|
||||
对 `erp-auth/src/module.rs`、`erp-config/src/module.rs`、`erp-workflow/src/module.rs`、`erp-message/src/module.rs`:
|
||||
- 将 `pub fn public_routes<S>()` 关联函数改为 `fn public_routes(&self) -> Option<Router>` trait 方法
|
||||
- 同样处理 `protected_routes`
|
||||
- 添加 `fn id()` 返回与 `name()` 相同值
|
||||
|
||||
每个模块的改动模式:
|
||||
```rust
|
||||
// 之前: pub fn public_routes<S>() -> Router<S> where ... { Router::new().route(...) }
|
||||
// 之后:
|
||||
fn public_routes(&self) -> Option<axum::Router> {
|
||||
Some(axum::Router::new().route("/auth/login", axum::routing::post(auth_handler::login)).route("/auth/refresh", axum::routing::post(auth_handler::refresh)))
|
||||
}
|
||||
fn protected_routes(&self) -> Option<axum::Router> { Some(...) }
|
||||
fn id(&self) -> &str { "auth" } // 与 name() 相同
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 更新 main.rs 使用 build_routes**
|
||||
|
||||
`crates/erp-server/src/main.rs`:
|
||||
```rust
|
||||
// 替换手动 merge 为:
|
||||
let (public_mod, protected_mod) = registry.build_routes();
|
||||
let public_routes = Router::new()
|
||||
.merge(handlers::health::health_check_router())
|
||||
.merge(public_mod) // 替代 erp_auth::AuthModule::public_routes()
|
||||
.route("/docs/openapi.json", ...)
|
||||
...;
|
||||
let protected_routes = protected_mod // 替代手动 merge 四个模块
|
||||
.merge(handlers::audit_log::audit_log_router())
|
||||
...;
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 运行 `cargo check --workspace` 确认全 workspace 编译通过**
|
||||
|
||||
- [ ] **Step 9: 运行 `cargo test --workspace` 确认测试通过**
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```
|
||||
feat(core): upgrade ErpModule trait v2 with lifecycle hooks, route methods, and auto-collection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: EventBus subscribe_filtered 扩展
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-core/src/events.rs`
|
||||
|
||||
- [ ] **Step 1: 添加类型化订阅支持**
|
||||
|
||||
在 `events.rs` 中扩展 `EventBus`:
|
||||
|
||||
```rust
|
||||
use std::sync::RwLock;
|
||||
|
||||
pub type EventHandler = Box<dyn Fn(DomainEvent) + Send + Sync>;
|
||||
pub type SubscriptionId = Uuid;
|
||||
|
||||
pub struct EventBus {
|
||||
sender: broadcast::Sender<DomainEvent>,
|
||||
handlers: Arc<RwLock<HashMap<String, Vec<(SubscriptionId, EventHandler)>>>>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
pub fn subscribe_filtered(
|
||||
&self,
|
||||
event_type: &str,
|
||||
handler: EventHandler,
|
||||
) -> SubscriptionId {
|
||||
let id = Uuid::now_v7();
|
||||
let mut handlers = self.handlers.write().unwrap();
|
||||
handlers.entry(event_type.to_string())
|
||||
.or_default()
|
||||
.push((id, handler));
|
||||
id
|
||||
}
|
||||
|
||||
pub fn unsubscribe(&self, id: SubscriptionId) {
|
||||
let mut handlers = self.handlers.write().unwrap();
|
||||
for (_, list) in handlers.iter_mut() {
|
||||
list.retain(|(sid, _)| *sid != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
修改 `broadcast()` 方法,在广播时同时分发给 `handlers` 中匹配的处理器。
|
||||
|
||||
- [ ] **Step 2: 运行 `cargo test --workspace`**
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(core): add typed event subscription to EventBus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 数据库迁移 + erp-plugin-runtime Crate
|
||||
|
||||
### Task 3: 插件数据库表迁移
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/migration/src/m20260413_000032_create_plugins_table.rs`
|
||||
- Modify: `crates/erp-server/migration/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 编写迁移文件**
|
||||
|
||||
创建 `plugins`、`plugin_schema_versions`、`plugin_event_subscriptions` 三张表(DDL 参见 spec §7.1)。
|
||||
|
||||
- [ ] **Step 2: 注册到 lib.rs 的迁移列表**
|
||||
|
||||
- [ ] **Step 3: 运行 `cargo run -p erp-server` 验证迁移执行**
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat(db): add plugins, plugin_schema_versions, and plugin_event_subscriptions tables
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 创建 erp-plugin-runtime crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-runtime/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/error.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/manifest.rs`
|
||||
- Modify: `Cargo.toml` (workspace members)
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core = { workspace = true }
|
||||
wasmtime = "27"
|
||||
wasmtime-wasi = "27"
|
||||
wit-bindgen = "0.24"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = "0.8"
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新根 Cargo.toml workspace members + dependencies**
|
||||
|
||||
- [ ] **Step 3: 实现 manifest.rs — PluginManifest 类型 + 解析**
|
||||
|
||||
定义 `PluginManifest`、`PluginInfo`、`PermissionSet`、`EntityDef`、`FieldDef`、`PageDef` 等结构体,实现 `fn parse(toml_str: &str) -> Result<PluginManifest>`。
|
||||
|
||||
- [ ] **Step 4: 实现 error.rs**
|
||||
|
||||
```rust
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginError {
|
||||
#[error("Manifest 解析失败: {0}")]
|
||||
ManifestParse(String),
|
||||
#[error("WASM 加载失败: {0}")]
|
||||
WasmLoad(String),
|
||||
#[error("Host API 错误: {0}")]
|
||||
HostApi(String),
|
||||
#[error("插件未找到: {0}")]
|
||||
NotFound(String),
|
||||
#[error("依赖未满足: {0}")]
|
||||
DependencyUnmet(String),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 实现 lib.rs — crate 入口 + re-exports**
|
||||
|
||||
- [ ] **Step 6: 运行 `cargo check --workspace`**
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): create erp-plugin-runtime crate with manifest parsing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: WIT 接口定义
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/wit/plugin.wit`
|
||||
|
||||
- [ ] **Step 1: 编写 WIT 文件**
|
||||
|
||||
参见 spec 附录 D.1 的完整 `plugin.wit` 内容(host interface + plugin interface + plugin-world)。
|
||||
|
||||
- [ ] **Step 2: 验证 WIT 语法**
|
||||
|
||||
```bash
|
||||
cargo install wit-bindgen-cli
|
||||
wit-bindgen rust ./crates/erp-plugin-runtime/wit/plugin.wit --out-dir /tmp/test-bindgen
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): define WIT interface for host-plugin contract
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Host API + 插件加载器
|
||||
|
||||
### Task 6: 实现 Host API 层
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/src/host_api.rs`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginHostState 结构体**
|
||||
|
||||
持有 `db`、`tenant_id`、`plugin_id`、`event_bus` 等上下文。实现 `db_insert`、`db_query`、`db_update`、`db_delete`、`db_aggregate` 方法,每个方法都:
|
||||
1. 自动注入 `tenant_id` 过滤
|
||||
2. 自动注入标准字段(id, created_at 等)
|
||||
3. 参数化 SQL 防注入
|
||||
4. 自动审计日志
|
||||
|
||||
- [ ] **Step 2: 注册为 Wasmtime host functions**
|
||||
|
||||
使用 `wasmtime::Linker::func_wrap` 将 host_api 方法注册到 WASM 实例。
|
||||
|
||||
- [ ] **Step 3: 编写单元测试**
|
||||
|
||||
使用 mock 数据库测试 db_insert 自动注入 tenant_id、db_query 自动过滤。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): implement host API layer with tenant isolation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 实现插件加载器 + 动态建表
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/src/engine.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/loader.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/schema.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/wasm_module.rs`
|
||||
|
||||
- [ ] **Step 1: engine.rs — Wasmtime Engine 封装**
|
||||
|
||||
单例 Engine + Store 工厂方法,配置内存限制(64MB 默认)、fuel 消耗限制。
|
||||
|
||||
- [ ] **Step 2: schema.rs — 从 manifest 动态建表**
|
||||
|
||||
`create_entity_table(db, entity_def)` 函数:生成 `CREATE TABLE IF NOT EXISTS plugin_{name} (...)` SQL,包含所有标准字段 + tenant_id 索引。
|
||||
|
||||
- [ ] **Step 3: loader.rs — 从数据库加载 + 实例化**
|
||||
|
||||
`load_plugins(db, engine, event_bus) -> Vec<LoadedPlugin>`:查询 `plugins` 表中 status=enabled 的记录,实例化 WASM,调用 init(),注册事件处理器。
|
||||
|
||||
- [ ] **Step 4: wasm_module.rs — WasmModule(实现 ErpModule trait)**
|
||||
|
||||
包装 WASM 实例,实现 ErpModule trait 的各方法(调用 WASM 导出函数)。
|
||||
|
||||
- [ ] **Step 5: 集成测试**
|
||||
|
||||
测试完整的 load → init → db_insert → db_query 流程(使用真实 PostgreSQL)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): implement plugin loader with dynamic schema creation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 插件管理 API + 数据 CRUD API
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/src/handlers/plugin.rs`
|
||||
- Modify: `crates/erp-server/src/main.rs`
|
||||
- Modify: `crates/erp-server/src/state.rs`
|
||||
|
||||
- [ ] **Step 1: 实现 plugin handler**
|
||||
|
||||
上传(解析 plugin.toml + 存储 wasm_binary)、列表、详情、启用(建表+写状态)、停用、卸载(软删除)。
|
||||
|
||||
- [ ] **Step 2: 实现插件数据 CRUD**
|
||||
|
||||
`GET/POST/PUT/DELETE /api/v1/plugins/{plugin_id}/{entity}` — 动态路由,从 manifest 查找 entity,调用 host_api 执行操作。
|
||||
|
||||
- [ ] **Step 3: 注册路由到 main.rs**
|
||||
|
||||
- [ ] **Step 4: 添加 PluginState 到 state.rs**
|
||||
|
||||
```rust
|
||||
impl FromRef<AppState> for erp_plugin_runtime::PluginState { ... }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行 `cargo test --workspace`**
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
feat(server): add plugin management and dynamic CRUD API endpoints
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: 前端配置驱动 UI
|
||||
|
||||
### Task 9: PluginStore + API Service
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/api/plugins.ts`
|
||||
- Create: `apps/web/src/stores/plugin.ts`
|
||||
|
||||
- [ ] **Step 1: plugins.ts API service**
|
||||
|
||||
接口类型定义 + API 函数:`listPlugins`、`getPlugin`、`uploadPlugin`、`enablePlugin`、`disablePlugin`、`uninstallPlugin`、`getPluginConfig`、`updatePluginConfig`、`getPluginData`、`createPluginData`、`updatePluginData`、`deletePluginData`。
|
||||
|
||||
- [ ] **Step 2: plugin.ts PluginStore**
|
||||
|
||||
```typescript
|
||||
interface PluginStore {
|
||||
plugins: PluginInfo[];
|
||||
loading: boolean;
|
||||
fetchPlugins(): Promise<void>;
|
||||
getPageConfigs(): PluginPageConfig[];
|
||||
}
|
||||
```
|
||||
|
||||
启动时调用 `fetchPlugins()` 加载已启用插件列表及页面配置。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(web): add plugin API service and PluginStore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: PluginCRUDPage 通用渲染引擎
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/PluginCRUDPage.tsx`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginCRUDPage 组件**
|
||||
|
||||
接收 `PluginPageConfig` 作为 props,渲染:
|
||||
- **SearchBar**: 从 `filters` 配置生成 Ant Design Form.Item 搜索条件
|
||||
- **DataTable**: 从 `columns` 配置生成 Ant Design Table 列
|
||||
- **FormDialog**: 从 `form` 配置或自动推导的 `schema.entities` 字段生成新建/编辑 Modal 表单
|
||||
- **ActionBar**: 从 `actions` 配置生成操作按钮
|
||||
|
||||
API 调用统一走 `/api/v1/plugins/{plugin_id}/{entity}` 路径。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
feat(web): implement PluginCRUDPage config-driven rendering engine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 动态路由 + 动态菜单
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/components/DynamicMenu.tsx`
|
||||
- Modify: `apps/web/src/App.tsx`
|
||||
- Modify: `apps/web/src/layouts/MainLayout.tsx`
|
||||
|
||||
- [ ] **Step 1: DynamicMenu 组件**
|
||||
|
||||
从 `usePluginStore` 读取 `getPageConfigs()`,按 `menu_group` 分组生成 Ant Design Menu.Item,追加到侧边栏。
|
||||
|
||||
- [ ] **Step 2: App.tsx 添加动态路由**
|
||||
|
||||
在 private routes 中,遍历 PluginStore 的 pageConfigs,为每个 CRUD 页面生成:
|
||||
```tsx
|
||||
<Route path={page.path} element={<PluginCRUDPage config={page} />} />
|
||||
```
|
||||
同时添加 `/plugin-admin` 路由指向 `PluginAdmin` 页面。
|
||||
|
||||
- [ ] **Step 3: MainLayout.tsx 集成 DynamicMenu**
|
||||
|
||||
替换硬编码的 `bizMenuItems`,追加插件动态菜单。
|
||||
|
||||
- [ ] **Step 4: 运行 `pnpm dev` 验证前端编译通过**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(web): add dynamic routing and menu generation from plugin configs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 插件管理页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/PluginAdmin.tsx`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginAdmin 页面**
|
||||
|
||||
包含:插件列表(Table)、上传按钮(Upload)、启用/停用/卸载操作、配置编辑 Modal。使用 Ant Design 组件。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
feat(web): add plugin admin page with upload/enable/disable/configure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: 第一个行业插件(进销存)
|
||||
|
||||
### Task 13: 创建 erp-plugin-inventory
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/plugins/inventory/Cargo.toml`
|
||||
- Create: `crates/plugins/inventory/plugin.toml`
|
||||
- Create: `crates/plugins/inventory/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 创建插件项目**
|
||||
|
||||
`Cargo.toml` crate-type = ["cdylib"],依赖 wit-bindgen + serde + serde_json。
|
||||
|
||||
- [ ] **Step 2: 编写 plugin.toml**
|
||||
|
||||
完整清单(spec §4 的进销存示例):inventory_item、purchase_order 两个 entity,3 个 CRUD 页面 + 1 个 custom 页面。
|
||||
|
||||
- [ ] **Step 3: 实现 lib.rs**
|
||||
|
||||
使用 wit-bindgen 生成的绑定,实现 `init()`、`on_tenant_created()`、`handle_event()`。
|
||||
|
||||
- [ ] **Step 4: 编译为 WASM**
|
||||
|
||||
```bash
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo build -p erp-plugin-inventory --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(inventory): create erp-plugin-inventory as first industry plugin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: 端到端集成测试
|
||||
|
||||
- [ ] **Step 1: 启动后端服务**
|
||||
|
||||
```bash
|
||||
cd docker && docker compose up -d
|
||||
cd crates/erp-server && cargo run
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 通过 API 上传进销存插件**
|
||||
|
||||
```bash
|
||||
# 打包
|
||||
cp target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm /tmp/
|
||||
# 上传
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/upload \
|
||||
-F "wasm=@/tmp/erp_plugin_inventory.wasm" \
|
||||
-F "manifest=@crates/plugins/inventory/plugin.toml"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 启用插件 + 验证建表**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/erp-inventory/enable
|
||||
docker exec erp-postgres psql -U erp -c "\dt plugin_*"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 测试 CRUD API**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/plugins/erp-inventory/inventory_item \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"sku":"ITEM001","name":"测试商品","quantity":100}'
|
||||
curl http://localhost:3000/api/v1/plugins/erp-inventory/inventory_item \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 前端验证**
|
||||
|
||||
启动 `pnpm dev`,验证:
|
||||
- 侧边栏出现"进销存"菜单组 + 子菜单
|
||||
- 点击"商品管理"显示 PluginCRUDPage
|
||||
- 可以新建/编辑/删除/搜索商品
|
||||
|
||||
- [ ] **Step 6: 测试停用 + 卸载**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/erp-inventory/disable
|
||||
curl -X DELETE http://localhost:3000/api/v1/admin/plugins/erp-inventory
|
||||
# 验证数据表仍在
|
||||
docker exec erp-postgres psql -U erp -c "\dt plugin_*"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```
|
||||
test(inventory): end-to-end integration test for plugin lifecycle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
```
|
||||
Chunk 1 (Tasks 1-2) ← 先做,所有后续依赖 trait v2 和 EventBus 扩展
|
||||
↓
|
||||
Chunk 2 (Tasks 3-5) ← 数据库表 + crate 骨架 + WIT
|
||||
↓
|
||||
Chunk 3 (Tasks 6-8) ← 核心运行时 + API(后端完成)
|
||||
↓
|
||||
Chunk 4 (Tasks 9-12) ← 前端(可与 Chunk 5 并行)
|
||||
↓
|
||||
Chunk 5 (Tasks 13-14) ← 第一个插件 + E2E 验证
|
||||
```
|
||||
|
||||
## 关键风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| Wasmtime 版本与 WIT 不兼容 | 锁定 wasmtime = "27",CI 验证 |
|
||||
| axum Router 在 erp-core 中引入重依赖 | 考虑将 trait routes 方法改为返回路由描述结构体,在 erp-server 层构建 Router |
|
||||
| 动态建表安全性 | 仅允许白名单列类型,禁止 DDL 注入 |
|
||||
| 前端 PluginCRUDPage 覆盖不足 | 先支持 text/number/date/select/currency,custom 页面后续迭代 |
|
||||
1026
docs/archive/superpowers-early/2026-04-16-crm-plugin-design.md
Normal file
1026
docs/archive/superpowers-early/2026-04-16-crm-plugin-design.md
Normal file
File diff suppressed because it is too large
Load Diff
1602
docs/archive/superpowers-early/2026-04-16-crm-plugin-plan.md
Normal file
1602
docs/archive/superpowers-early/2026-04-16-crm-plugin-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,767 @@
|
||||
# CRM 插件基座升级设计规格 v1.0
|
||||
|
||||
> **文档状态:** v1.1 — 已修复评审问题
|
||||
> **创建日期:** 2026-04-17
|
||||
> **范围:** JSONB 存储优化 + 数据完整性框架 + 行级数据权限 + 前端页面能力增强
|
||||
> **评审记录:** code-reviewer 子代理评审通过一轮修复(3 Critical + 7 Important)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
CRM 插件是 ERP 平台的第一个 WASM 行业插件,已完成 3 阶段 24 任务,包含 5 实体、9 权限、7 页面类型。经 6 个专家组深度评审,发现以下结构性问题需要优先解决:
|
||||
|
||||
| 问题 | 严重级别 | 影响 |
|
||||
|------|---------|------|
|
||||
| JSONB 动态表类型安全缺失、排序全表扫描 | High | 万级数据以上性能崩溃 |
|
||||
| JSONB 零外键完整性、零级联策略 | High | 数据"脏"掉,引用断裂 |
|
||||
| 行级数据权限缺失 | Critical | 销售A能看到销售B的所有客户 |
|
||||
| plugin.admin 权限 fallback 过宽 | Critical | 超级用户权限泄露 |
|
||||
| 无关联选择器 (entity_select) | High | UX 极差,客户ID手动输入 |
|
||||
| 无看板/批量操作/图表等页面能力 | Medium | CRM 功能不完整 |
|
||||
|
||||
**核心原则:** 基座优先。所有改进沉淀为插件平台通用能力,CRM 作为第一受益者而非唯一受益者。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
1. **JSONB 存储优化** — 百万级数据下列表查询 p95 < 200ms,搜索 p95 < 300ms
|
||||
2. **数据完整性框架** — 应用层外键校验、级联策略、字段校验、循环引用检测
|
||||
3. **行级数据权限** — 支持 self/department/department_tree/all 四级数据范围
|
||||
4. **前端页面能力增强** — 关联选择器、看板页面、批量操作、Dashboard 图表、visible_when 增强
|
||||
|
||||
---
|
||||
|
||||
## 3. JSONB 存储优化
|
||||
|
||||
### 3.1 Generated Column 混合存储
|
||||
|
||||
利用 PostgreSQL 12+ 的 `GENERATED ALWAYS AS ... STORED` 列,自动从 JSONB `data` 列提取高频字段到独立列。数据只存一份(在 JSONB 中),Generated Column 是自动派生的,零维护成本。
|
||||
|
||||
**提取规则(在 `dynamic_table.rs` 的 `create_table` 中自动判断):**
|
||||
|
||||
| 字段特征 | 提取策略 | 原因 |
|
||||
|----------|---------|------|
|
||||
| `unique == true` | Generated Column + UNIQUE INDEX | 需要精确唯一性约束 |
|
||||
| `required == true && (sortable \|\| filterable)` | Generated Column + INDEX | 需要类型化排序/筛选 |
|
||||
| `sortable == true` | Generated Column + INDEX | ORDER BY 走 B-tree |
|
||||
| `filterable == true` | Generated Column + INDEX | WHERE 走索引扫描 |
|
||||
| `searchable == true` | 保留 JSONB + pg_trgm GIN 索引 | 模糊搜索用三元组索引 |
|
||||
| 其他字段 | 保留 JSONB | 无需索引 |
|
||||
|
||||
**生成的 DDL 示例:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE plugin_erp_crm_customer (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
-- Generated Columns
|
||||
_f_code TEXT GENERATED ALWAYS AS (data->>'code') STORED,
|
||||
_f_name TEXT GENERATED ALWAYS AS (data->>'name') STORED,
|
||||
_f_customer_type TEXT GENERATED ALWAYS AS (data->>'customer_type') STORED,
|
||||
_f_status TEXT GENERATED ALWAYS AS (data->>'status') STORED,
|
||||
_f_level TEXT GENERATED ALWAYS AS (data->>'level') STORED,
|
||||
-- 标准字段
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID,
|
||||
updated_by UUID,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
version INT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- 复合索引(tenant_id 在前,支持多租户过滤)
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_tenant_cover
|
||||
ON "{t}" (tenant_id, created_at DESC)
|
||||
INCLUDE (id, data, updated_at, version)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_f_name_sort
|
||||
ON "{t}" (tenant_id, _f_name)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_{t}_f_code_uniq
|
||||
ON "{t}" (tenant_id, _f_code)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_f_type_filter
|
||||
ON "{t}" (tenant_id, _f_customer_type)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**SQL 查询路由:** 在 `dynamic_table.rs` 中新增 `GeneratedColumnInfo` 结构,记录哪些字段被提取为 Generated Column。`build_filtered_query_sql` 和 `build_aggregate_sql` 检测到对应 Generated Column 存在时,自动将 `data->>'field'` 替换为 `_f_{field}`。
|
||||
|
||||
**类型映射:** `data->>'field'` 始终返回 TEXT。对于非字符串类型,Generated Column 需要类型转换以支持正确的排序和比较:
|
||||
|
||||
| field_type | SQL 类型 | Generated Column 表达式 |
|
||||
|------------|---------|------------------------|
|
||||
| String | TEXT | `data->>'field'` |
|
||||
| Integer | INTEGER | `(data->>'field')::INTEGER` |
|
||||
| Float | DOUBLE PRECISION | `(data->>'field')::DOUBLE PRECISION` |
|
||||
| Decimal | NUMERIC(18,4) | `(data->>'field')::NUMERIC` |
|
||||
| Boolean | BOOLEAN | `(data->>'field')::BOOLEAN` |
|
||||
| Date | DATE | `(data->>'field')::DATE` |
|
||||
| DateTime | TIMESTAMPTZ | `(data->>'field')::TIMESTAMPTZ` |
|
||||
| Uuid | UUID | `(data->>'field')::UUID` |
|
||||
|
||||
`dynamic_table.rs` 的 `create_table` 根据 `PluginField.field_type` 自动选择正确的 SQL 类型和类型转换表达式。
|
||||
|
||||
**元数据表:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS plugin_entity_columns (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL, -- 多租户标准字段
|
||||
plugin_entity_id UUID NOT NULL REFERENCES plugin_entities(id),
|
||||
field_name VARCHAR(100) NOT NULL,
|
||||
column_name VARCHAR(100) NOT NULL, -- 如 _f_name
|
||||
sql_type VARCHAR(50) NOT NULL, -- 如 TEXT, INTEGER, UUID
|
||||
is_generated BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Schema 演变策略(重新安装/字段变更):**
|
||||
|
||||
当前 `service.rs` 的 `install` 使用 `CREATE TABLE IF NOT EXISTS`。引入 Generated Column 后,安装流程改为:
|
||||
|
||||
1. **首次安装**:`CREATE TABLE` 包含所有 Generated Column。
|
||||
2. **重新安装(同版本)**:`IF NOT EXISTS` 跳过表创建。比对 `plugin_entity_columns` 元数据与当前 manifest 的字段列表,执行增量 ALTER:
|
||||
- 新增字段:`ALTER TABLE ADD COLUMN _f_{name} {type} GENERATED ALWAYS AS (...) STORED`
|
||||
- 删除字段:`ALTER TABLE DROP COLUMN _f_{name}`(仅删除 Generated Column,JSONB data 中的原始值保留)
|
||||
- 类型变更:PostgreSQL 不支持 ALTER GENERATED COLUMN 的表达式,需 DROP + ADD
|
||||
3. **插件卸载时**:表被删除,元数据自动清理。
|
||||
|
||||
`dynamic_table.rs` 新增 `migrate_table` 方法,接受已有列列表和目标列列表,生成增量 DDL。
|
||||
|
||||
### 3.2 pg_trgm 模糊搜索加速
|
||||
|
||||
**迁移文件:** 在 `erp-server/migration` 中新增迁移启用 pg_trgm 扩展:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
```
|
||||
|
||||
**索引创建:** `create_table` 中 searchable 字段的索引从普通 B-tree 改为 GIN 三元组:
|
||||
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_{f}_trgm
|
||||
ON "{t}" USING GIN ((data->>'{f}') gin_trgm_ops)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
启用后 `ILIKE '%keyword%'` 从全表扫描退化为索引扫描,百万级数据搜索从 2-5s 降至 50-200ms。
|
||||
|
||||
### 3.3 Keyset Pagination
|
||||
|
||||
**向后兼容设计:** API 同时支持 OFFSET 和 cursor 两种分页模式。
|
||||
|
||||
`data_dto.rs` 中 `PluginDataListParams` 新增 `cursor` 字段:
|
||||
|
||||
```rust
|
||||
pub struct PluginDataListParams {
|
||||
pub page: Option<u64>, // 保留,向后兼容
|
||||
pub page_size: Option<u64>,
|
||||
pub cursor: Option<String>, // 新增:Base64 编码的游标
|
||||
pub search: Option<String>,
|
||||
pub filter: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
`dynamic_table.rs` 中 SQL 构建逻辑:当 `cursor` 存在时使用 keyset 分页:
|
||||
|
||||
**游标编码格式:** JSON 结构 `{ "v": [value1, value2, ...], "id": "uuid" }`,Base64 编码。`v` 数组存储排序字段的值(与 sort_by 顺序一致),`id` 是记录主键作为最终 tiebreaker。多列排序时 `v` 包含多个值。字段值为 null 时存储 JSON null。
|
||||
|
||||
客户端必须在每次请求中同时发送 `cursor` 和 `sort_by`/`sort_order`(游标不嵌入排序信息,保持无状态)。
|
||||
|
||||
```sql
|
||||
-- 第一页
|
||||
SELECT ... ORDER BY _f_name ASC, id ASC LIMIT 20;
|
||||
|
||||
-- 后续页(cursor 解码后)
|
||||
SELECT ... WHERE (_f_name, id) > ($cursor_sort_val, $cursor_id)
|
||||
ORDER BY _f_name ASC, id ASC LIMIT 20;
|
||||
```
|
||||
|
||||
### 3.4 Schema 缓存
|
||||
|
||||
在 `PluginState` 中添加 `moka` LRU 缓存,消除每次数据请求的 `resolve_entity_info` 查库:
|
||||
|
||||
```rust
|
||||
pub entity_cache: Cache<String, EntityInfo>, // key: "{plugin_id}:{entity_name}:{tenant_id}"
|
||||
```
|
||||
|
||||
TTL 5 分钟,容量 1000 条。
|
||||
|
||||
### 3.5 聚合 Redis 缓存
|
||||
|
||||
`data_service.rs` 的 create/update/delete 成功后增量更新 Redis 统计:
|
||||
|
||||
```
|
||||
plugin:{plugin_id}:{entity}:count:{tenant_id} → 计数值
|
||||
plugin:{plugin_id}:{entity}:agg:{field}:{tenant_id} → JSON {key: count}
|
||||
```
|
||||
|
||||
Dashboard 查询直接从 Redis 读取,TTL 5 分钟兜底。
|
||||
|
||||
### 3.6 性能 SLA 目标
|
||||
|
||||
**测试条件:** PostgreSQL 与应用同机部署(Redis localhost 延迟 < 1ms)。SLA 包含 Redis 往返(schema 缓存 + 部门缓存)。冷启动(Redis 缓存未命中)首次查询允许 3x SLA 宽限。
|
||||
|
||||
| 查询场景 | 数据量 | p50 | p95 | p99 |
|
||||
|----------|--------|-----|-----|-----|
|
||||
| 按 ID 获取单条 | 100万 | < 5ms | < 10ms | < 20ms |
|
||||
| 列表查询(默认排序) | 100万 | < 20ms | < 50ms | < 100ms |
|
||||
| 列表查询(字段排序) | 100万 | < 30ms | < 100ms | < 200ms |
|
||||
| 搜索(ILIKE) | 100万 | < 50ms | < 100ms | < 300ms |
|
||||
| 聚合查询 | 100万 | < 50ms (缓存) | < 500ms (实时) | - |
|
||||
| Dashboard 全量加载 | 100万 | < 200ms | < 500ms | - |
|
||||
|
||||
### 3.7 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 主要改动 — Generated Column DDL、索引策略、SQL 路由、keyset 分页 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 缓存逻辑、聚合 Redis 缓存 |
|
||||
| `crates/erp-plugin/src/data_dto.rs` | 新增 cursor 参数 |
|
||||
| `crates/erp-plugin/src/state.rs` | 新增 entity_cache |
|
||||
| `crates/erp-plugin/src/manifest.rs` | PluginEntityColumns 元数据 |
|
||||
| `crates/erp-server/migration/src/` | pg_trgm 扩展 + plugin_entity_columns 表 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据完整性框架
|
||||
|
||||
### 4.1 外键引用声明
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `ref_entity` 字段:
|
||||
|
||||
```rust
|
||||
pub struct PluginField {
|
||||
pub name: String,
|
||||
pub field_type: PluginFieldType,
|
||||
// ...已有字段...
|
||||
pub ref_entity: Option<String>, // 新增:引用的实体名
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "所属客户"
|
||||
ref_entity = "customer" # 声明外键引用
|
||||
```
|
||||
|
||||
### 4.2 应用层外键校验
|
||||
|
||||
在 `data_service.rs` 的 `validate_data` 函数中扩展:
|
||||
|
||||
```
|
||||
create/update 时:
|
||||
遍历 fields,如果 field.ref_entity 存在:
|
||||
1. 从 data 中取出该字段的 UUID 值
|
||||
2. 如果值为 null 或空字符串且 required == false → 跳过校验
|
||||
3. 如果是自引用(ref_entity == 当前实体名)且为 create 操作:
|
||||
a. 如果引用的是自身 ID → 跳过(记录尚不存在,无法校验)
|
||||
b. 如果引用的是其他记录 → 正常校验
|
||||
4. 查询 ref_entity 对应的动态表,验证该记录存在且未删除
|
||||
5. 不存在则返回 ValidationError
|
||||
|
||||
TOCTOU 竞态说明:
|
||||
外键校验与引用记录删除之间存在理论上的竞态窗口。
|
||||
对于 JSONB 动态表,这是可接受的风险——应用层校验已大幅降低孤立引用概率。
|
||||
如果未来需要严格保证,可在 flush_ops 中增加二次校验(事务内 SELECT FOR UPDATE)。
|
||||
```
|
||||
|
||||
### 4.3 级联删除策略
|
||||
|
||||
`manifest.rs` 新增 `PluginRelation` 结构:
|
||||
|
||||
```rust
|
||||
pub struct PluginRelation {
|
||||
pub entity: String, // 关联实体名
|
||||
pub foreign_key: String, // 关联实体中的外键字段名
|
||||
pub on_delete: OnDeleteStrategy, // 级联策略
|
||||
}
|
||||
|
||||
pub enum OnDeleteStrategy {
|
||||
Nullify, // 置空外键字段
|
||||
Cascade, // 级联软删除
|
||||
Restrict, // 存在关联时拒绝删除
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.relations]]
|
||||
entity = "contact"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "nullify"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "communication"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer_tag"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
```
|
||||
|
||||
`data_service.rs` 的 `delete` 方法中,在软删除记录之前:
|
||||
|
||||
```
|
||||
1. 从 manifest 中查找该实体声明的所有 relations
|
||||
2. 对每个 relation:
|
||||
- Restrict: 查询关联实体是否有引用 → 有则拒绝删除
|
||||
- Nullify: 批量 UPDATE 关联记录,将 foreign_key 设为 null
|
||||
- Cascade: 批量软删除关联记录(级联深度上限 3 层,防止 A→B→C→D 无限递归)
|
||||
```
|
||||
|
||||
### 4.4 字段校验规则
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `validation` 子结构:
|
||||
|
||||
```rust
|
||||
pub struct FieldValidation {
|
||||
pub pattern: Option<String>, // 正则表达式
|
||||
pub message: Option<String>, // 校验失败提示
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "phone"
|
||||
field_type = "string"
|
||||
display_name = "手机号"
|
||||
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "email"
|
||||
field_type = "string"
|
||||
display_name = "邮箱"
|
||||
validation = { pattern = "^[\\w.-]+@[\\w.-]+\\.\\w+$", message = "邮箱格式不正确" }
|
||||
```
|
||||
|
||||
`validate_data` 扩展:对有 `validation.pattern` 的字段,使用 `regex` crate 做正则匹配。
|
||||
|
||||
### 4.5 循环引用检测
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `no_cycle` 字段:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "parent_id"
|
||||
field_type = "uuid"
|
||||
ref_entity = "customer"
|
||||
no_cycle = true # 声明不允许循环引用
|
||||
```
|
||||
|
||||
`data_service.rs` 的 `update` 方法中,当 `no_cycle == true` 的字段被修改时:
|
||||
|
||||
```
|
||||
1. 从 data 中取出新值 (new_parent_id)
|
||||
2. 初始化 visited = {record_id}
|
||||
3. 循环:查询 current 的 parent_id → 如果在 visited 中则报错 → 加入 visited
|
||||
4. 直到 parent_id 为 null 或到达根节点
|
||||
```
|
||||
|
||||
### 4.6 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/manifest.rs` | 新增 ref_entity / PluginRelation / FieldValidation / no_cycle |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 外键校验 / 级联删除 / 字段校验 / 循环检测 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | 为现有字段添加 ref_entity / relations / validation / no_cycle 声明 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 行级数据权限
|
||||
|
||||
### 5.1 数据范围模型
|
||||
|
||||
在实体级别声明是否启用行级数据权限,在权限级别声明数据范围等级。
|
||||
|
||||
**manifest 扩展:**
|
||||
|
||||
```toml
|
||||
[[schema.entities]]
|
||||
name = "customer"
|
||||
display_name = "客户"
|
||||
data_scope = true # 启用行级数据权限
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "owner_id"
|
||||
field_type = "uuid"
|
||||
display_name = "负责人"
|
||||
scope_role = "owner" # 标记为数据权限的"所有者"字段
|
||||
```
|
||||
|
||||
**权限声明扩展:**
|
||||
|
||||
```toml
|
||||
[[permissions]]
|
||||
code = "customer.list"
|
||||
name = "查看客户"
|
||||
data_scope_levels = ["self", "department", "department_tree", "all"]
|
||||
```
|
||||
|
||||
### 5.2 数据范围等级定义
|
||||
|
||||
| 等级 | 含义 | SQL 条件 |
|
||||
|------|------|---------|
|
||||
| `self` | 只看自己负责/创建的 | `data->>'owner_id' = current_user_id OR created_by = current_user_id` |
|
||||
| `department` | 看本部门所有人的 | `data->>'owner_id' IN (部门用户列表)` |
|
||||
| `department_tree` | 看本部门及下级部门 | `data->>'owner_id' IN (部门树用户列表)` |
|
||||
| `all` | 看全部 | 无额外条件 |
|
||||
|
||||
### 5.3 实现路径
|
||||
|
||||
**TenantContext 扩展:** `erp-core` 的 `TenantContext` 结构新增 `department_ids: Vec<Uuid>` 字段(注意:用户可通过岗位属于多个部门)。JWT claims 中新增 `dept_ids` 字段,JWT 中间件在构造 TenantContext 时填充。
|
||||
|
||||
**多部门用户处理:** 用户通过 Position 关联到多个 Department。`department` 级别取所有所属部门的并集;`department_tree` 取所有所属部门及其下级部门的并集。没有岗位/部门的用户在 `department` 和 `department_tree` 级别下只能看到自己创建的数据(降级为 self)。
|
||||
|
||||
**角色权限表扩展:** `role_permissions` 表新增 `data_scope` 字段(VARCHAR(32),默认值 `'all'`)。新增迁移文件 `m20260418_*_add_data_scope_to_role_permissions.rs`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE role_permissions ADD COLUMN IF NOT EXISTS data_scope VARCHAR(32) NOT NULL DEFAULT 'all';
|
||||
```
|
||||
|
||||
**管理界面适配:** 角色权限分配界面新增"数据范围"下拉选项,管理员为每个权限分配时选择 self/department/department_tree/all。
|
||||
|
||||
**查询注入:** `data_service.rs` 的 `list` / `count` / `aggregate` 方法中:
|
||||
|
||||
```
|
||||
1. 从权限检查结果中获取该权限对应的 data_scope 等级
|
||||
2. 如果实体启用了 data_scope:
|
||||
- self: 注入 owner_id / created_by 过滤条件
|
||||
- department: 查询用户所在部门的所有用户 ID,注入 IN 条件
|
||||
- department_tree: 递归查询部门树,注入 IN 条件
|
||||
- all: 无额外条件
|
||||
3. 将条件追加到 dynamic_table 的 SQL 构建中
|
||||
```
|
||||
|
||||
**部门用户缓存:** 使用 Redis 缓存部门-用户映射关系,TTL 10 分钟,避免每次查询都递归查部门树。当部门分配变更时通过 EventBus 事件 (`department.member_changed`) 失效缓存。
|
||||
|
||||
### 5.4 权限 fallback 收紧
|
||||
|
||||
**当前行为(危险):** `data_handler.rs` 中,如果没有实体级权限,fallback 到 `plugin.admin`,获得所有数据访问权。
|
||||
|
||||
**修改后:** 移除 fallback 逻辑。权限检查链改为:
|
||||
|
||||
```
|
||||
1. 检查实体级权限 ({manifest_id}.{entity}.{action})
|
||||
2. 存在 → 通过,附带 data_scope
|
||||
3. 不存在 → 拒绝 (403)
|
||||
```
|
||||
|
||||
`plugin.admin` 只管理插件生命周期(上传/安装/启用/禁用/卸载),不自动获得数据访问权。需要显式分配实体级权限。
|
||||
|
||||
**迁移策略(避免现有管理员失去访问):** 在收紧 fallback 的迁移中,同时执行以下补偿:
|
||||
|
||||
```sql
|
||||
-- 为所有拥有 plugin.admin 权限的角色,自动分配所有已安装插件的实体级权限
|
||||
-- data_scope 默认设为 'all'(管理员级别)
|
||||
INSERT INTO role_permissions (id, role_id, permission_id, tenant_id, data_scope, ...)
|
||||
SELECT gen_random_uuid(), rp.role_id, p.id, rp.tenant_id, 'all', ...
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.tenant_id = rp.tenant_id
|
||||
WHERE rp.permission_id = (SELECT id FROM permissions WHERE code = 'plugin.admin')
|
||||
AND p.code LIKE 'erp-%' -- 所有插件实体权限
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp2
|
||||
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p.id
|
||||
);
|
||||
```
|
||||
|
||||
这确保现有管理员在 fallback 收紧后仍保持完整的数据访问能力。
|
||||
|
||||
### 5.5 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-core/src/types.rs` | TenantContext 新增 department_ids 字段 |
|
||||
| `crates/erp-auth/src/middleware/jwt_auth.rs` | JWT claims 解析 department_ids |
|
||||
| `crates/erp-plugin/src/manifest.rs` | data_scope / scope_role / data_scope_levels |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 查询条件注入 |
|
||||
| `crates/erp-plugin/src/handler/data_handler.rs` | 移除权限 fallback |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | SQL 构建支持数据范围条件 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | customer 实体添加 data_scope / owner_id |
|
||||
| `crates/erp-server/migration/src/` | 新增 data_scope 列 + 权限补偿迁移 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端页面能力增强
|
||||
|
||||
### 6.1 关联选择器 (entity_select)
|
||||
|
||||
**Schema 扩展:** `PluginFieldSchema` 新增字段:
|
||||
|
||||
```typescript
|
||||
interface PluginFieldSchema {
|
||||
// ...已有字段...
|
||||
ref_entity?: string; // 引用的实体名
|
||||
ref_label_field?: string; // 显示字段
|
||||
ref_search_fields?: string[]; // 搜索字段
|
||||
cascade_from?: string; // 级联过滤来源字段
|
||||
cascade_filter?: string; // 级联过滤目标字段
|
||||
}
|
||||
```
|
||||
|
||||
**新增组件:** `EntitySelect.tsx` — 通用远程搜索选择器
|
||||
|
||||
```
|
||||
Props: pluginId, entity, labelField, searchFields, cascadeFrom?, cascadeFilter?, value?, onChange?
|
||||
内部: listPluginData(pluginId, entity, {search, filter}) → Ant Design Select + showSearch
|
||||
```
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
display_name = "所属客户"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "customer"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name", "code"]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "contact_id"
|
||||
field_type = "uuid"
|
||||
display_name = "关联联系人"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "contact"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name"]
|
||||
cascade_from = "customer_id" # 选了客户后自动过滤
|
||||
cascade_filter = "customer_id"
|
||||
```
|
||||
|
||||
### 6.2 Kanban 看板页面
|
||||
|
||||
**Schema 扩展:** `PluginPageType` 新增 `Kanban` 变体。
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "kanban"
|
||||
entity = "customer"
|
||||
label = "销售漏斗"
|
||||
icon = "swap"
|
||||
lane_field = "level"
|
||||
lane_order = ["potential", "normal", "vip", "svip"]
|
||||
card_title_field = "name"
|
||||
card_subtitle_field = "code"
|
||||
card_fields = ["name", "code", "region", "status"]
|
||||
enable_drag = true
|
||||
```
|
||||
|
||||
**新增组件:** `PluginKanbanPage.tsx`
|
||||
|
||||
- 使用 `@dnd-kit/core` + `@dnd-kit/sortable` 实现跨列拖拽
|
||||
- 每列使用 Ant Design Card 渲染卡片
|
||||
- 每列内支持虚拟滚动(节点数 > 50 时)
|
||||
- 拖拽结束调用 `PATCH /plugins/{id}/{entity}/{recordId}` 更新 lane_field 值
|
||||
|
||||
**后端新增:** `PATCH` 部分更新端点(当前只有 PUT 全量更新):
|
||||
|
||||
```
|
||||
PATCH /api/v1/plugins/{plugin_id}/{entity}/{id}
|
||||
Body: { "data": { "level": "vip" }, "version": 3 }
|
||||
```
|
||||
|
||||
与 PUT 的区别:PATCH 只更新 data 中提供的字段,未提供的字段保持不变。
|
||||
|
||||
### 6.3 批量操作
|
||||
|
||||
**CRUD 页面增强:** `PluginCRUDPage.tsx` 新增 `rowSelection` 和批量操作栏。
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "customer"
|
||||
enable_batch = true
|
||||
|
||||
[[ui.pages.batch_actions]]
|
||||
label = "批量删除"
|
||||
action = "batch_delete"
|
||||
permission = "customer.manage"
|
||||
confirm = true
|
||||
|
||||
[[ui.pages.batch_actions]]
|
||||
label = "批量修改状态"
|
||||
action = "batch_update"
|
||||
update_field = "status"
|
||||
permission = "customer.manage"
|
||||
```
|
||||
|
||||
**后端新增:** `POST /api/v1/plugins/{id}/{entity}/batch`
|
||||
|
||||
```rust
|
||||
pub enum BatchAction {
|
||||
BatchDelete { ids: Vec<Uuid> },
|
||||
BatchUpdate { ids: Vec<Uuid>, data: serde_json::Value },
|
||||
}
|
||||
```
|
||||
|
||||
批量操作在单个事务中执行,有上限(默认 100 条)。
|
||||
|
||||
### 6.4 visible_when 表达式增强
|
||||
|
||||
**当前:** 只支持 `field == 'value'` 单一等式。
|
||||
|
||||
**增强后支持:**
|
||||
|
||||
```toml
|
||||
visible_when = "customer_type == 'enterprise'"
|
||||
visible_when = "customer_type == 'enterprise' AND level == 'vip'"
|
||||
visible_when = "status == 'active' OR status == 'pending'"
|
||||
visible_when = "NOT status == 'blacklist'"
|
||||
visible_when = "customer_type == 'enterprise' AND (level == 'vip' OR level == 'svip')"
|
||||
```
|
||||
|
||||
**前端实现:** 新建 `exprEvaluator.ts`,约 100 行递归下降表达式解析器:
|
||||
|
||||
```typescript
|
||||
interface ExprNode {
|
||||
type: 'eq' | 'and' | 'or' | 'not';
|
||||
field?: string;
|
||||
value?: string;
|
||||
left?: ExprNode;
|
||||
right?: ExprNode;
|
||||
operand?: ExprNode;
|
||||
}
|
||||
|
||||
function parseExpr(input: string): ExprNode;
|
||||
function evaluateExpr(node: ExprNode, values: Record<string, unknown>): boolean;
|
||||
```
|
||||
|
||||
不引入外部依赖,不使用 eval。
|
||||
|
||||
### 6.5 Dashboard 图表增强
|
||||
|
||||
**Schema 扩展:** Dashboard 页面支持 widgets 声明:
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "统计概览"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "stat_card"
|
||||
entity = "customer"
|
||||
title = "客户总数"
|
||||
icon = "team"
|
||||
color = "#4F46E5"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "bar_chart"
|
||||
entity = "customer"
|
||||
title = "客户地区分布"
|
||||
dimension_field = "region"
|
||||
metric = "count"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "pie_chart"
|
||||
entity = "customer"
|
||||
title = "客户类型分布"
|
||||
dimension_field = "customer_type"
|
||||
metric = "count"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "funnel_chart"
|
||||
entity = "customer"
|
||||
title = "客户等级漏斗"
|
||||
dimension_field = "level"
|
||||
dimension_order = ["potential", "normal", "vip", "svip"]
|
||||
metric = "count"
|
||||
```
|
||||
|
||||
**图表库:** 使用 `@ant-design/charts`(Ant Design 生态一致,支持按需引入)。
|
||||
|
||||
**后端新增:** timeseries 聚合 API:
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{id}/{entity}/timeseries?time_field=occurred_at&time_grain=week&start=2026-01-01&end=2026-04-17
|
||||
|
||||
响应:{ "data": [{ "period": "2026-W01", "count": 12 }, ...] }
|
||||
```
|
||||
|
||||
SQL 实现:`date_trunc('week', (data->>'occurred_at')::timestamp)`
|
||||
|
||||
**数据钻取:** 图表点击维度值时跳转到 CRUD 页面并自动带上筛选条件。`PluginCRUDPage` 支持从 URL query 参数初始化筛选。
|
||||
|
||||
### 6.6 前端文件拆分
|
||||
|
||||
| 当前文件 | 行数 | 拆分方案 |
|
||||
|---------|------|---------|
|
||||
| `PluginGraphPage.tsx` | 1081 | → `graphRenderer.ts` + `graphLayout.ts` + `graphInteraction.ts` |
|
||||
| `PluginCRUDPage.tsx` | 617 | → `CrudTable.tsx` + `CrudForm.tsx` + `CrudDetail.tsx` |
|
||||
| `PluginDashboardPage.tsx` | 647 | → `DashboardWidgets.tsx` + `dashboardTypes.ts` |
|
||||
|
||||
拆分后每个文件控制在 400 行以内。
|
||||
|
||||
### 6.7 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `apps/web/src/components/EntitySelect.tsx` | 新增 |
|
||||
| `apps/web/src/pages/PluginKanbanPage.tsx` | 新增 |
|
||||
| `apps/web/src/utils/exprEvaluator.ts` | 新增 |
|
||||
| `apps/web/src/pages/PluginCRUDPage.tsx` | 重构 — 拆分 + 批量操作 + entity_select + visible_when |
|
||||
| `apps/web/src/pages/PluginGraphPage.tsx` | 重构 — 拆分 |
|
||||
| `apps/web/src/pages/PluginDashboardPage.tsx` | 重构 — 图表 + 拆分 |
|
||||
| `apps/web/src/pages/PluginTreePage.tsx` | 优化 — 懒加载 |
|
||||
| `apps/web/src/api/plugins.ts` | Schema 类型扩展 |
|
||||
| `apps/web/src/api/pluginData.ts` | 新增 batch / timeseries / cursor API |
|
||||
| `apps/web/src/App.tsx` | Kanban 路由注册 |
|
||||
| `crates/erp-plugin/src/handler/data_handler.rs` | 新增 PATCH / batch 端点 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | batch / timeseries / partial update(PATCH 只合并 data 中的字段) |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 新增 `build_patch_sql` 部分更新 SQL 构建器 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| Generated Column 的 ALTER TABLE 锁表 | 中 | 中 | 插件安装时在低峰期执行;万级数据以内锁表时间 < 1s |
|
||||
| pg_trgm 索引空间开销(约 2-3x 原始文本) | 低 | 低 | 只为 searchable 的短文本字段创建 |
|
||||
| 行级权限的部门查询性能 | 中 | 中 | Redis 缓存部门树,TTL 10 分钟 |
|
||||
| 批量操作事务过大 | 低 | 中 | 上限 100 条;超过则分批执行 |
|
||||
| 前端重构引入回归 | 中 | 高 | 逐文件拆分,每步验证现有功能不变 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 不在范围内(后续版本)
|
||||
|
||||
以下内容在本次设计中**不涉及**,记录为已知需求:
|
||||
|
||||
- WASM Guest 业务逻辑增强 (L2/L3 插件模型)
|
||||
- 插件版本升级迁移框架
|
||||
- 跨插件通信 (事件契约 + 只读查询)
|
||||
- 插件间 RPC / 自定义 API 端点
|
||||
- 插件市场 / 分发架构
|
||||
- CRM 新增实体 (lead / opportunity / activity)
|
||||
- WIT 接口版本化
|
||||
- 图谱 LOD + WebGL 渲染
|
||||
- Iframe / Web Component 自定义 UI
|
||||
|
||||
这些将在后续的设计规格中详细展开。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,604 @@
|
||||
# CRM 插件平台标杆 — P0 基础能力设计规格
|
||||
|
||||
> **版本**: v1.1 (修正版 — 基于代码审查发现,对齐现有实现)
|
||||
> **日期**: 2026-04-18
|
||||
> **状态**: Draft
|
||||
> **定位**: 插件平台标杆 — CRM 是试金石,打磨通用能力
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
### 1.1 为什么要做这个
|
||||
|
||||
CRM 插件是 ERP 平台的第一个行业插件,当前状态是"客户通讯录 + 标签 + 关系图谱",距离一流 CRM(Salesforce/HubSpot/Pipedrive)有显著差距。但更大的问题是:**CRM 暴露的差距不在于 CRM 本身,而在于插件平台的基础能力缺失。**
|
||||
|
||||
具体来说:
|
||||
- ~~5 个实体之间有明确的 FK 关系,但 manifest 无法声明~~ → **已有 `PluginRelation` + 级联删除**,但缺少 `name`/`display_field`/关系类型等前端渲染信息
|
||||
- 35+ 字段有 required/unique/pattern 校验,但缺少 `min_length`/`max_length`/`min_value`/`max_value` 扩展校验
|
||||
- Dashboard/Graph 页面硬编码了 CRM 专属颜色和标题,第二个插件无法复用
|
||||
- CRM 的 `plugin.toml` 没有声明 `relations`,导致现有级联能力未被使用
|
||||
- 批量删除和 PATCH 部分更新绕过了现有校验
|
||||
|
||||
如果不在 P0 阶段补齐这些基础,所有后续业务功能(商机、合同、报价)都会建在不稳固的地基上。
|
||||
|
||||
### 1.2 设计原则
|
||||
|
||||
| 原则 | 含义 |
|
||||
|------|------|
|
||||
| **平台优先** | 每个能力都是平台层的,CRM 只是第一个使用者 |
|
||||
| **零改动复用** | inventory/生产/财务插件不应为这些能力写任何额外代码 |
|
||||
| **Manifest 驱动** | 所有行为由 plugin.toml 声明驱动,不写硬编码 |
|
||||
| **双层保障** | 前端即时反馈 + 后端最终防线,缺一不可 |
|
||||
|
||||
### 1.3 一流 CRM 差距分析摘要
|
||||
|
||||
| 类别 | 差距 | 本规格是否覆盖 |
|
||||
|------|------|--------------|
|
||||
| 实体关系 + 级联删除 | 致命 — 删除客户产生孤儿数据 | **P0-1 覆盖** |
|
||||
| 字段校验 + FK 完整性 | 严重 — 数据质量无保障 | **P0-2 覆盖** |
|
||||
| 前端通用化 | 中等 — 第二个插件无法复用 Dashboard/Graph | **P0-3 覆盖** |
|
||||
| 商机/漏斗/合同 | 严重 — 核心业务缺失 | P2(本规格不覆盖) |
|
||||
| 导入导出/批量操作 | 中等 — ERP 刚需 | P1(后续规格) |
|
||||
| 全局搜索/保存视图 | 中等 — UX 缺失 | P1(后续规格) |
|
||||
| WASM 活化 | 低 — 当前空操作不影响功能 | P2(后续规格) |
|
||||
|
||||
---
|
||||
|
||||
## 2. P0-1: 实体关系声明 + ref_entity + 级联策略
|
||||
|
||||
### 2.1 Manifest Schema 扩展
|
||||
|
||||
**现有基础**:`PluginRelation` 已存在(`manifest.rs:184-189`),包含 `entity`、`foreign_key`、`on_delete` 三个字段。级联删除已在 `data_service.rs:330-395` 中实现。
|
||||
|
||||
**扩展方向**:在现有结构上新增字段,保持向后兼容。
|
||||
|
||||
```toml
|
||||
# === 一对多关系 (customer → contacts) ===
|
||||
[[schema.entities.relations]]
|
||||
entity = "contact" # 目标实体 (已有字段)
|
||||
foreign_key = "customer_id" # FK 字段 (已有字段)
|
||||
on_delete = "cascade" # cascade | nullify | restrict (已有枚举)
|
||||
# ↓ 新增字段 (可选,向后兼容)
|
||||
name = "contacts" # 关系显示名,用于前端标签
|
||||
type = "one_to_many" # 关系类型 (one_to_many | many_to_one | many_to_many)
|
||||
display_field = "name" # EntitySelect 下拉显示字段
|
||||
|
||||
# === 多对一关系 (contact → customer,含自引用) ===
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer"
|
||||
foreign_key = "parent_id"
|
||||
on_delete = "nullify"
|
||||
name = "parent"
|
||||
type = "many_to_one"
|
||||
display_field = "name"
|
||||
|
||||
# === 多对多关系 (customer ↔ customer,通过中间表) ===
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer"
|
||||
foreign_key = "from_customer_id" # 中间表中的源 FK
|
||||
on_delete = "nullify"
|
||||
name = "related_customers"
|
||||
type = "many_to_many"
|
||||
through_entity = "customer_relationship"
|
||||
through_source_field = "from_customer_id"
|
||||
through_target_field = "to_customer_id"
|
||||
```
|
||||
|
||||
#### 关系类型定义 (新增 `type` 字段)
|
||||
|
||||
| 类型 | 含义 | foreign_key 位置 | CRM 场景 |
|
||||
|------|------|-----------------|---------|
|
||||
| `one_to_many` | 一个父 → 多个子 | 子实体上 | customer → contacts |
|
||||
| `many_to_one` | 多个子 → 一个父 | 本实体上 | contact → customer |
|
||||
| `many_to_many` | 双向多对多 | 中间表上 | customer ↔ customer |
|
||||
|
||||
> `type` 字段为 `Option<RelationType>`,默认 `OneToMany`。不声明则现有行为不变。
|
||||
|
||||
#### 级联策略 (保持现有枚举不变)
|
||||
|
||||
| 策略 | TOML 值 | 行为 | 适用场景 |
|
||||
|------|---------|------|---------|
|
||||
| `Cascade` | `"cascade"` | 子记录 `deleted_at = now()` | 强所有权:客户→联系人 |
|
||||
| `Nullify` | `"nullify"` | FK 字段设 NULL | 弱引用:联系人→上级客户 |
|
||||
| `Restrict` | `"restrict"` | 有子记录时阻止删除(409) | 关键数据:不允许孤立 |
|
||||
|
||||
### 2.2 后端实现
|
||||
|
||||
#### 数据结构扩展 (`manifest.rs`)
|
||||
|
||||
**在现有 `PluginRelation` 上新增字段**(不替换):
|
||||
|
||||
```rust
|
||||
// 现有字段保持不变
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginRelation {
|
||||
pub entity: String, // 已有
|
||||
pub foreign_key: String, // 已有
|
||||
pub on_delete: OnDeleteStrategy, // 已有 (Cascade | Nullify | Restrict)
|
||||
// ↓ 新增可选字段
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub relation_type: Option<RelationType>,
|
||||
#[serde(default)]
|
||||
pub display_field: Option<String>,
|
||||
// many_to_many 专属
|
||||
#[serde(default)]
|
||||
pub through_entity: Option<String>,
|
||||
#[serde(default)]
|
||||
pub through_source_field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub through_target_field: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RelationType {
|
||||
#[default]
|
||||
OneToMany,
|
||||
ManyToOne,
|
||||
ManyToMany,
|
||||
}
|
||||
```
|
||||
|
||||
#### 级联删除 (已有,需增强)
|
||||
|
||||
`data_service.rs:330-395` 已实现 `Restrict`/`Nullify`/`Cascade` 三种策略。需增强:
|
||||
|
||||
1. **级联影响信息返回**:Restrict 时返回 `affected_count` 和 `relation.name`,方便前端展示
|
||||
2. **批量删除级联**:`batch_delete` (data_service.rs:417-520) 当前绕过级联,需补充
|
||||
3. **many_to_many 级联**:基于 `through_entity` 清理中间表记录
|
||||
|
||||
#### 级联策略执行 (已有,需增强错误信息)
|
||||
|
||||
现有 `data_service.rs:330-395` 已实现。增强点:
|
||||
|
||||
1. **Restrict 错误增强**:返回 `affected_count` 和 `relation.name`
|
||||
2. **批量删除级联**:`batch_delete` (data_service.rs:417-520) 当前绕过级联,需补充
|
||||
3. **PATCH 校验**:`partial_update` (data_service.rs:291-327) 当前绕过 `validate_data`,需补充
|
||||
4. **many_to_many 级联**:基于 `through_entity` 清理中间表记录
|
||||
|
||||
#### FK 存在性校验 (已有 `validate_ref_entities`)
|
||||
|
||||
`data_service.rs:834-899` 已实现 `validate_ref_entities`。需确保 `partial_update` (PATCH) 也调用此函数。
|
||||
|
||||
### 2.3 前端实现
|
||||
|
||||
#### 前端类型扩展
|
||||
|
||||
`apps/web/src/api/plugins.ts` 需更新:
|
||||
|
||||
```typescript
|
||||
// PluginEntitySchema 新增
|
||||
interface PluginEntitySchema {
|
||||
// ... existing fields
|
||||
relations?: PluginRelationSchema[];
|
||||
}
|
||||
|
||||
interface PluginRelationSchema {
|
||||
entity: string;
|
||||
foreign_key: string;
|
||||
on_delete: 'cascade' | 'nullify' | 'restrict';
|
||||
name?: string;
|
||||
type?: 'one_to_many' | 'many_to_one' | 'many_to_many';
|
||||
display_field?: string;
|
||||
}
|
||||
|
||||
// PluginFieldSchema 新增 validation 属性
|
||||
interface PluginFieldSchema {
|
||||
// ... existing fields
|
||||
validation?: {
|
||||
pattern?: string;
|
||||
message?: string;
|
||||
min_length?: number;
|
||||
max_length?: number;
|
||||
min_value?: number;
|
||||
max_value?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### EntitySelect 增强 (已有基础)
|
||||
|
||||
字段有 `ref_entity` 属性时,CRUD 表单已自动渲染为 EntitySelect。增强点:
|
||||
- 优先使用 `relation.display_field` 作为下拉显示字段(fallback 到现有 `ref_label_field`)
|
||||
- 关联子表标题使用 `relation.name`
|
||||
|
||||
#### 详情页关联子表自动渲染
|
||||
|
||||
Entity 的 `one_to_many` relations 自动在详情页渲染为内嵌 CRUD 表格:
|
||||
- Compact 模式 + 自动过滤 `fk = parent_record.id`
|
||||
- 支持新增/编辑/删除子记录
|
||||
- 标题使用 `relation.name`
|
||||
|
||||
#### 级联删除确认
|
||||
|
||||
删除有 incoming relations 的记录时,弹出确认:
|
||||
```
|
||||
确定删除客户「{name}」?
|
||||
此操作将同时删除:
|
||||
- 3 条联系人记录
|
||||
- 5 条沟通记录
|
||||
- 2 条标签记录
|
||||
```
|
||||
|
||||
### 2.4 CRM plugin.toml 改造
|
||||
|
||||
为 customer 实体补充 relations:
|
||||
|
||||
```toml
|
||||
[[schema.entities.relations]]
|
||||
entity = "contact"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
name = "contacts"
|
||||
type = "one_to_many"
|
||||
display_field = "name"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "communication"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
name = "communications"
|
||||
type = "one_to_many"
|
||||
display_field = "subject"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer_tag"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
name = "tags"
|
||||
type = "one_to_many"
|
||||
display_field = "tag_name"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer"
|
||||
foreign_key = "parent_id"
|
||||
on_delete = "nullify"
|
||||
name = "parent"
|
||||
type = "many_to_one"
|
||||
display_field = "name"
|
||||
```
|
||||
|
||||
为 contact 实体补充 relations:
|
||||
|
||||
```toml
|
||||
[[schema.entities.relations]]
|
||||
entity = "communication"
|
||||
foreign_key = "contact_id"
|
||||
on_delete = "cascade"
|
||||
name = "communications"
|
||||
type = "one_to_many"
|
||||
display_field = "subject"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. P0-2: 字段校验层
|
||||
|
||||
### 3.1 现有基础
|
||||
|
||||
**已有实现**:
|
||||
- `validate_data` (`data_service.rs:797-831`): required + pattern 正则校验
|
||||
- `validate_ref_entities` (`data_service.rs:834-899`): FK 引用存在性校验
|
||||
- `FieldValidation` (`manifest.rs:53-57`): `pattern` + `message` 字段
|
||||
- unique 检查已在 `create`/`update` 流程中实现
|
||||
|
||||
**缺失部分**:
|
||||
- `min_length` / `max_length` 校验器
|
||||
- `min_value` / `max_value` 校验器
|
||||
- PATCH (partial_update) 绕过所有校验
|
||||
- 前端 TypeScript 类型缺少 `validation` 属性
|
||||
|
||||
### 3.2 Manifest Schema 扩展
|
||||
|
||||
在现有 `[validation]` 上新增字段(`manifest.rs:53-57` 已有 `pattern` + `message`):
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "phone"
|
||||
field_type = "string"
|
||||
display_name = "手机号"
|
||||
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^1[3-9]\\d{9}$"
|
||||
message = "请输入有效的手机号码"
|
||||
min_length = 11
|
||||
max_length = 11
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "credit_limit"
|
||||
field_type = "decimal"
|
||||
|
||||
[schema.entities.fields.validation]
|
||||
min_value = 0
|
||||
max_value = 99999999
|
||||
message = "信用额度必须在 0-99999999 之间"
|
||||
```
|
||||
|
||||
#### 校验类型定义
|
||||
|
||||
| 校验器 | manifest 字段 | 状态 | 说明 |
|
||||
|--------|-------------|------|------|
|
||||
| `required` | `field.required` | **已有** | 值不能为 null/空字符串 |
|
||||
| `unique` | `field.unique` | **已有** | 同 tenant 内值唯一 |
|
||||
| `pattern` | `validation.pattern` + `validation.message` | **已有** | 正则匹配 |
|
||||
| `ref_exists` | `field.ref_entity` | **已有** | FK 指向的记录存在且未删除 |
|
||||
| `min_length` / `max_length` | `validation.min_length` / `validation.max_length` | **新增** | 字符串长度范围 |
|
||||
| `min_value` / `max_value` | `validation.min_value` / `validation.max_value` | **新增** | 数值范围 |
|
||||
|
||||
### 3.3 后端实现
|
||||
|
||||
#### 扩展 `FieldValidation` (`manifest.rs:53-57`)
|
||||
|
||||
在现有结构上新增 4 个可选字段:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FieldValidation {
|
||||
pub pattern: Option<String>, // 已有
|
||||
pub message: Option<String>, // 已有
|
||||
// ↓ 新增
|
||||
pub min_length: Option<usize>,
|
||||
pub max_length: Option<usize>,
|
||||
pub min_value: Option<f64>,
|
||||
pub max_value: Option<f64>,
|
||||
}
|
||||
```
|
||||
|
||||
#### 扩展 `validate_data` (`data_service.rs:797-831`)
|
||||
|
||||
在现有函数中追加 min_length/max_length/min_value/max_value 检查:
|
||||
|
||||
```rust
|
||||
// 现有: required + pattern 检查 (已实现)
|
||||
// 新增:
|
||||
if let Some(validation) = &field.validation {
|
||||
// min_length / max_length
|
||||
if let Some(str_val) = val.as_str() {
|
||||
if let Some(min) = validation.min_length {
|
||||
if str_val.len() < min { return Err(...); }
|
||||
}
|
||||
if let Some(max) = validation.max_length {
|
||||
if str_val.len() > max { return Err(...); }
|
||||
}
|
||||
}
|
||||
// min_value / max_value (适用于 number/integer/decimal)
|
||||
if let Some(num_val) = val.as_f64() {
|
||||
if let Some(min) = validation.min_value {
|
||||
if num_val < min { return Err(...); }
|
||||
}
|
||||
if let Some(max) = validation.max_value {
|
||||
if num_val > max { return Err(...); }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 修复 PATCH 校验缺失
|
||||
|
||||
`partial_update` (`data_service.rs:291-327`) 需要添加 `validate_data` 和 `validate_ref_entities` 调用,与 `update` 保持一致。
|
||||
|
||||
**执行位置:** `data_service.rs` 的 `create_record` 和 `update_record` 方法中,数据写入前调用 `validate_record`。
|
||||
|
||||
**错误响应格式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "数据验证失败",
|
||||
"details": [
|
||||
{ "field": "phone", "message": "请输入有效的手机号码" },
|
||||
{ "field": "customer_id", "message": "引用的客户不存在" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 前端实现
|
||||
|
||||
从 schema 自动生成 Ant Design Form rules(需先修复 TypeScript 类型缺失):
|
||||
|
||||
```typescript
|
||||
function generateFormRules(field: PluginFieldSchema): Rule[] {
|
||||
const rules: Rule[] = [];
|
||||
|
||||
if (field.required) {
|
||||
rules.push({ required: true, message: `${field.display_name}不能为空` });
|
||||
}
|
||||
|
||||
if (field.validation?.pattern) {
|
||||
rules.push({
|
||||
pattern: new RegExp(field.validation.pattern),
|
||||
message: field.validation.message || `${field.display_name}格式不正确`,
|
||||
});
|
||||
}
|
||||
|
||||
if (field.validation?.min_length || field.validation?.max_length) {
|
||||
rules.push({
|
||||
min: field.validation.min_length,
|
||||
max: field.validation.max_length,
|
||||
message: field.validation.message || `${field.display_name}长度不正确`,
|
||||
});
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 CRM plugin.toml 补充校验
|
||||
|
||||
```toml
|
||||
# phone 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^1[3-9]\\d{9}$"
|
||||
message = "请输入有效的手机号码"
|
||||
|
||||
# email 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"
|
||||
message = "请输入有效的邮箱地址"
|
||||
|
||||
# credit_code 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^[0-9A-HJ-NP-RTUW-Y]{2}\\d{6}[0-9A-HJ-NP-RTUW-Y]{10}$"
|
||||
message = "请输入有效的统一社会信用代码"
|
||||
|
||||
# website 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^https?://[\\w.-]+(?:\\.[\\w.-]+)+[/#?]?.*$"
|
||||
message = "请输入有效的网址"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. P0-3: 前端去硬编码
|
||||
|
||||
### 4.1 Dashboard 通用化
|
||||
|
||||
**涉及文件:**
|
||||
- `apps/web/src/pages/dashboard/dashboardConstants.tsx`
|
||||
- `apps/web/src/pages/dashboard/DashboardWidgets.tsx`
|
||||
- `apps/web/src/pages/PluginDashboardPage.tsx`
|
||||
|
||||
**改造方案:**
|
||||
|
||||
| 当前硬编码 | 通用化方案 |
|
||||
|-----------|-----------|
|
||||
| `ENTITY_COLORS`: customer→indigo, contact→green, ... | 8 色调色板按 entity 顺序自动分配 |
|
||||
| `ENTITY_ICONS`: customer→TeamOutlined, ... | 从 page schema 的 icon 字段读取 |
|
||||
| 标题 "CRM 数据全景视图" | `{manifest.name} 统计概览` |
|
||||
| 副标题 "实时掌握业务动态" | `{manifest.description}` 截取前 50 字 |
|
||||
|
||||
**通用调色板:**
|
||||
|
||||
```typescript
|
||||
const UNIVERSAL_PALETTE = [
|
||||
'#6366f1', // indigo
|
||||
'#22c55e', // green
|
||||
'#f59e0b', // amber
|
||||
'#8b5cf6', // violet
|
||||
'#ef4444', // red
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#ec4899', // pink
|
||||
];
|
||||
```
|
||||
|
||||
### 4.2 Graph 通用化
|
||||
|
||||
**涉及文件:** `apps/web/src/pages/plugins/graph/graphConstants.ts`
|
||||
|
||||
**改造方案:**
|
||||
|
||||
| 当前硬编码 | 通用化方案 |
|
||||
|-----------|-----------|
|
||||
| `RELATIONSHIP_COLORS`: parent_child→indigo, ... | 调色板按 option 顺序循环 |
|
||||
| `RELATIONSHIP_LABELS`: parent_child→"母子", ... | 从 field.options[].label 读取 |
|
||||
| `RELATIONSHIP_TYPES` 固定 5 种 | 从 schema 动态生成 |
|
||||
|
||||
### 4.3 CRUD 表格列可配置
|
||||
|
||||
**涉及文件:** `apps/web/src/pages/PluginCRUDPage.tsx`
|
||||
|
||||
**改造方案:**
|
||||
|
||||
manifest page 新增可选字段 `table_columns`:
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "customer"
|
||||
table_columns = ["code", "name", "customer_type", "level", "status", "owner_id", "region", "industry"]
|
||||
```
|
||||
|
||||
不声明时默认行为:
|
||||
- 取前 8 个非 hidden 非 FK 字段
|
||||
- 替换当前 `fields.slice(0, 5)` 硬编码
|
||||
|
||||
### 4.4 验证标准
|
||||
|
||||
> **测试: 将 CRM 插件替换为 inventory 插件,Dashboard/Graph/CRUD 页面应零改动正确渲染。**
|
||||
|
||||
具体验证:
|
||||
1. Dashboard 显示 inventory 的 6 个实体统计,颜色按顺序分配
|
||||
2. Graph 如果 inventory 有关系数据,渲染正确(无数据则显示空状态)
|
||||
3. CRUD 表格按 `table_columns` 或默认 8 列显示
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键文件清单
|
||||
|
||||
### 后端 Rust
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `crates/erp-plugin/src/manifest.rs` | 修改 | `PluginRelation` 新增 name/type/display_field/through_* 字段;`FieldValidation` 新增 min_length/max_length/min_value/max_value |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 修改 | 扩展 `validate_data` 增加 min/max 校验;`partial_update` 补充校验调用;`batch_delete` 补充级联 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | 修改 | 补充 relations 声明 + validation 规则 |
|
||||
|
||||
> 注意:不新建 `validation.rs`,直接扩展现有 `validate_data` 和 `validate_ref_entities`。
|
||||
|
||||
### 前端 TypeScript
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `apps/web/src/api/plugins.ts` | 修改 | `PluginEntitySchema` 新增 `relations`;`PluginFieldSchema` 新增 `validation` |
|
||||
| `apps/web/src/pages/dashboard/dashboardConstants.tsx` | 修改 | 去硬编码,通用调色板自动分配 |
|
||||
| `apps/web/src/pages/dashboard/DashboardWidgets.tsx` | 修改 | schema 驱动颜色/图标 |
|
||||
| `apps/web/src/pages/PluginDashboardPage.tsx` | 修改 | 通用标题/副标题 |
|
||||
| `apps/web/src/pages/plugins/graph/graphConstants.ts` | 修改 | 关系类型从 options 动态读取 |
|
||||
| `apps/web/src/pages/PluginCRUDPage.tsx` | 修改 | 可配置列数 + Form rules 自动生成 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证方案
|
||||
|
||||
### 6.1 编译与测试
|
||||
|
||||
```bash
|
||||
cargo check # 全 workspace 编译
|
||||
cargo test --workspace # 全量测试
|
||||
```
|
||||
|
||||
### 6.2 单元测试
|
||||
|
||||
- `validation.rs`: 每种校验器独立测试 (required/unique/pattern/ref_exists/length/value range)
|
||||
- `data_service.rs`: 级联策略测试 (cascade_soft_delete/set_null/restrict)
|
||||
|
||||
### 6.3 集成测试 (Testcontainers)
|
||||
|
||||
- 删除客户 → 验证联系人/沟通记录/标签级联软删除
|
||||
- 删除有 restrict 关系的记录 → 验证 409 响应
|
||||
- 创建联系人 → customer_id 不存在时验证 400
|
||||
- 创建客户 → phone 格式不正确时验证 400 + 错误详情
|
||||
- 创建客户 → code 已存在时验证 409
|
||||
|
||||
### 6.4 功能验证
|
||||
|
||||
1. 重新安装 CRM 插件,确认 5 个 relation 正确注册到 entity metadata
|
||||
2. 删除客户 → 确认关联数据正确级联
|
||||
3. 手机号/邮箱格式校验 → 确认前后端双重拦截
|
||||
4. Dashboard → 确认标题/颜色从 schema 动态生成
|
||||
5. 切换 inventory 插件 → Dashboard/Graph 零改动渲染
|
||||
|
||||
### 6.5 前端验证
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm dev
|
||||
```
|
||||
|
||||
手动测试所有 CRM 页面,确认无回归。
|
||||
|
||||
---
|
||||
|
||||
## 7. 不在本规格范围内
|
||||
|
||||
| 项 | 原因 | 计划 |
|
||||
|----|------|------|
|
||||
| 商机 (Opportunity) / 销售漏斗 | CRM 业务功能,P2 范畴 | 后续规格 |
|
||||
| 数据导入导出 (Excel) | 平台能力但工作量大 | P1 规格 |
|
||||
| 通知规则 + 消息中心联动 | 需要跨模块协作 | P1 规格 |
|
||||
| WASM 校验/计算 Hook | 平台能力但依赖 WASM 运行时增强 | P2 规格 |
|
||||
| 全局搜索 / 保存视图 | UX 增强 | P1 规格 |
|
||||
| Lead 线索实体 | CRM 业务功能 | P2 规格 |
|
||||
@@ -0,0 +1,337 @@
|
||||
# ERP 插件平台演进路线图 — 设计规格
|
||||
|
||||
> 日期: 2026-04-18
|
||||
> 来源: 无主题发散式互动探讨
|
||||
> 状态: Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
ERP 平台已完成 Phase 1-6 核心开发和 Q2-Q4 成熟度路线图。当前有两个行业插件(CRM + 进销存)运行在 WASM 插件系统上。通过分析发现四大系统性缺口:
|
||||
|
||||
1. **跨插件数据引用完全不支持** — 进销存的 `customer_id` 只能存裸 UUID
|
||||
2. **插件无通用业务能力** — 导入导出/打印/配置/视图每个插件都要自己实现
|
||||
3. **无质量保障机制** — 第三方插件的安全性和性能无法保证
|
||||
4. **无发现和分发渠道** — 用户无法自助发现和安装插件
|
||||
|
||||
**目标:** 通过搭建财务/应收插件来验证和推动这些平台能力的实现。
|
||||
|
||||
**核心设计原则:**
|
||||
- 插件间**完全独立**,任何插件可自由安装/卸载,不受其他插件影响
|
||||
- 跨插件引用**声明式**,通过 plugin.toml 零代码实现
|
||||
- 通用业务能力**平台层提供**,插件声明式接入
|
||||
- 外部引用问题永远是**软警告**,永不硬阻塞用户操作
|
||||
|
||||
---
|
||||
|
||||
## 2. 跨插件数据引用系统
|
||||
|
||||
### 2.1 Entity Registry (平台实体注册表)
|
||||
|
||||
插件安装时将其所有实体注册到平台级 Entity Registry,其他插件通过 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.2 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.3 运行时行为
|
||||
|
||||
**写入时校验:**
|
||||
|
||||
| 源插件状态 | 写入行为 | 读取行为 | 前端展示 |
|
||||
|-----------|---------|---------|---------|
|
||||
| 已安装 (active) | 强校验 UUID 存在性 | JOIN 富化 display_field | ✅ 绿色链接 "张三" |
|
||||
| 未安装 (inactive) | 无校验,接受任意 UUID | 返回原始 UUID | ⬜ 灰色 "外部客户" |
|
||||
| 刚重新启用 | 新写入强校验,不回溯已有 | 后台对账扫描 | ⚠️ 黄色警告 (悬空) |
|
||||
|
||||
**悬空引用处理 (插件重新启用时):**
|
||||
1. 后台扫描所有 `ref_scope=external` 且指向本插件实体的字段
|
||||
2. 验证每个 UUID 是否存在于本插件表中
|
||||
3. 生成对账报告: `{ valid: N, dangling: M, details: [...] }`
|
||||
4. 前端展示对账结果,用户逐条处理(映射/清空/忽略)
|
||||
5. 永不硬阻塞用户操作
|
||||
|
||||
### 2.4 需要改造的文件
|
||||
|
||||
| 文件 | 改动 | 复杂度 |
|
||||
|------|------|--------|
|
||||
| `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
|
||||
[[schema.entities]]
|
||||
name = "invoice"
|
||||
display_name = "发票"
|
||||
importable = true
|
||||
exportable = true
|
||||
import_template = "invoice_import_template.xlsx"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 自动生成导入模板(基于 schema entities fields)
|
||||
- Excel/CSV 解析 + schema 字段校验
|
||||
- 批量写入(支持事务 + 错误行级报告)
|
||||
- 导出为 Excel/CSV(支持筛选条件)
|
||||
- 导入历史记录 + 回滚
|
||||
|
||||
**实现位置:** `crates/erp-plugin/src/import_export.rs` + 前端 `ImportExportModal` 通用组件
|
||||
|
||||
### 3.2 打印模板引擎
|
||||
|
||||
平台提供 HTML → PDF 的模板渲染能力,插件定义模板和字段映射。
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "invoice_pdf"
|
||||
display_name = "发票"
|
||||
entity = "invoice"
|
||||
format = "pdf"
|
||||
template_file = "templates/invoice.html"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- HTML 模板渲染 → PDF 下载
|
||||
- 模板变量替换(基于实体字段)
|
||||
- 租户级模板自定义(覆盖默认模板)
|
||||
- 打印预览
|
||||
|
||||
### 3.3 插件配置 UI
|
||||
|
||||
插件在 plugin.toml 中声明配置项,平台自动生成配置页面。
|
||||
|
||||
```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 声明自动生成配置表单
|
||||
- 配置数据存储在 `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
|
||||
[[trigger_events]]
|
||||
name = "invoice.overdue"
|
||||
display_name = "发票逾期"
|
||||
description = "发票超过付款期限未收款"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 规则引擎: WHEN event THEN notify [user/role/department]
|
||||
- 复用 erp-message 的通知渠道
|
||||
- 租户级规则配置
|
||||
|
||||
### 3.6 编号规则 (已有基础扩展)
|
||||
|
||||
复用 erp-config 的编号规则服务,扩展为插件可接入。
|
||||
|
||||
```toml
|
||||
[[numbering]]
|
||||
entity = "invoice"
|
||||
prefix = "INV"
|
||||
format = "{PREFIX}-{YEAR}-{SEQ:4}"
|
||||
reset_rule = "yearly"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- avg_response_ms: float
|
||||
- fuel_consumption: float
|
||||
- memory_peak_mb: float
|
||||
- active_instances: int
|
||||
```
|
||||
|
||||
**告警规则:** 错误率 > 5% / 平均响应 > 2s / Fuel 消耗异常 / 内存持续增长
|
||||
|
||||
---
|
||||
|
||||
## 5. 插件市场/商店
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 插件目录 | 按行业/功能分类浏览 |
|
||||
| 搜索 | 按名称/标签/行业搜索 |
|
||||
| 详情页 | 截图、演示、功能描述、权限说明 |
|
||||
| 一键安装 | 上传 → 自动安装 → 配置 → 启用 |
|
||||
| 评分/评论 | 用户评分和使用反馈 |
|
||||
| 版本管理 | 版本列表、更新日志、回滚 |
|
||||
| 依赖提示 | 安装时提示可选依赖 |
|
||||
|
||||
---
|
||||
|
||||
## 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 平台能力升级 + 插件系统增强
|
||||
|
||||
P1 (跨插件引用): Entity Registry + ref_scope 扩展 + 前端 entity_select 改造
|
||||
这是所有后续能力的基础
|
||||
|
||||
P2 (平台通用服务): 数据导入导出 → 插件配置 UI → 编号规则扩展 → 通知规则
|
||||
|
||||
P3 (质量保障): 上传时安全扫描 → 性能基准 → 运行时监控
|
||||
|
||||
P4 (插件市场): 插件目录 → 一键安装 → 版本管理 → 评分评论
|
||||
|
||||
验证: 财务/应收插件贯穿 P1-P2,每完成一个 P 就用财务插件验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| Entity Registry 查询性能 | 每次数据操作都要查注册表 | 内存缓存 + DashMap,注册表数据量极小 |
|
||||
| 悬空引用数据量过大 | 对账扫描耗时长 | 异步后台任务 + 分批处理 + 进度条 |
|
||||
| Excel 导入内存占用 | 大文件解析 OOM | 流式解析 + 批量提交 + 文件大小限制 |
|
||||
| 打印模板安全 | 模板注入攻击 | 沙箱渲染 + 变量白名单 |
|
||||
| 插件市场审核成本 | 人工审核效率低 | 自动化扫描 + 人工抽查 + 社区举报 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 讨论溯源
|
||||
|
||||
本文档基于 2026-04-18 的无主题发散式互动探讨产出,完整讨论过程记录在 `plans/skill-cosmic-pancake.md`。
|
||||
|
||||
关键决策历程:
|
||||
- **Round 1:** 发现跨插件数据引用完全不支持(进销存的 customer_id 是裸 UUID)
|
||||
- **Round 2:** 确定声明式引用 + 完全独立(无硬依赖)+ 软警告对账方案
|
||||
- **Round 3:** 确定导入导出/打印/配置/视图/通知应为平台通用服务
|
||||
- **Round 4:** 收敛为统一设计规格,以财务插件为验证载体
|
||||
@@ -0,0 +1,183 @@
|
||||
# 插件系统增强设计规格
|
||||
|
||||
## 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) event_bus: Option<EventBus>,
|
||||
}
|
||||
```
|
||||
|
||||
db_query 实现变更 — 使用 `tokio::runtime::Handle::current()` 在 `spawn_blocking` 内执行异步 DB 操作:
|
||||
|
||||
1. 先 `block_on(flush_ops(...))` 清空 pending writes
|
||||
2. 解析 filter/pagination 参数
|
||||
3. 调用 `DynamicTableManager::build_query_sql()` 构建查询
|
||||
4. `block_on` 执行查询并返回结果
|
||||
|
||||
向后兼容:`db = None` 时走旧的预填充路径。
|
||||
|
||||
#### 2. `crates/erp-plugin/src/dynamic_table.rs`
|
||||
|
||||
新增 `build_query_sql` 方法,复用 `data_service.rs` 中的查询构建逻辑。
|
||||
|
||||
### 向后兼容
|
||||
|
||||
- `HostState::new()` 不传 db → 走旧的预填充路径
|
||||
- `execute_wasm()` 传 db → 走新的实时查询路径
|
||||
- 现有 WASM 插件无需修改
|
||||
|
||||
---
|
||||
|
||||
## 改动 2:扩展聚合查询
|
||||
|
||||
### 问题
|
||||
|
||||
`data_service.rs:655` 的 `aggregate` 方法只支持 `GROUP BY + COUNT(*)`。
|
||||
|
||||
### 方案
|
||||
|
||||
新增 `aggregate_multi` 方法支持 SUM/AVG/MAX/MIN。
|
||||
|
||||
改动文件:
|
||||
|
||||
1. `data_service.rs` — 新增 `AggregateDef`、`AggregateFunc`、`AggregateResult` 类型和 `aggregate_multi` 方法
|
||||
2. `dynamic_table.rs` — 新增 `build_aggregate_multi_sql` 方法
|
||||
3. `data_handler.rs` — 扩展聚合 API 端点
|
||||
4. 前端 Dashboard Widget 适配多聚合返回格式
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 改动 3:热更新原子回滚
|
||||
|
||||
### 问题
|
||||
|
||||
`service.rs:578-585` — 先 `unload(old)` 再 `load(new)`,中间失败无回滚。
|
||||
|
||||
### 方案:先加载新版本到临时 key,成功后原子替换
|
||||
|
||||
改动文件:
|
||||
|
||||
1. `service.rs` — upgrade 方法改用临时 key 加载新版本
|
||||
2. `engine.rs` — 新增 `rename_plugin` 方法
|
||||
|
||||
安全保证:新版本加载失败 → 旧版本仍在运行,零停机。
|
||||
|
||||
---
|
||||
|
||||
## 改动 4:Schema 演进(ALTER TABLE 支持)
|
||||
|
||||
### 问题
|
||||
|
||||
升级时只处理新增实体(CREATE TABLE),不处理已有实体的字段变更。
|
||||
|
||||
### 方案:利用 JSONB 特性实现轻量级 Schema 演进
|
||||
|
||||
大部分字段变更不需要 DDL(JSONB 天然支持),仅新增 filterable/sortable 字段需 ALTER TABLE ADD Generated Column + 索引。
|
||||
|
||||
改动文件:
|
||||
|
||||
1. `service.rs` — upgrade 方法增加 schema diff 逻辑
|
||||
2. `dynamic_table.rs` — 新增 `FieldDiff`、`diff_entity_fields`、`alter_add_generated_columns`
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
| 阶段 | 改动 | 复杂度 | 影响范围 |
|
||||
|------|------|--------|---------|
|
||||
| 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. 上传损坏的 WASM 二进制 → 验证旧版本仍在运行
|
||||
2. 上传正确的新版本 → 验证成功切换
|
||||
|
||||
### 阶段 2:Schema 演进
|
||||
1. 升级插件增加 filterable 字段 → 验证 ALTER TABLE 正确执行
|
||||
2. 旧数据上新 Generated Column 值正确填充
|
||||
|
||||
### 阶段 3:聚合查询
|
||||
1. 创建测试数据,调用聚合 API → 验证 SUM/AVG 结果正确
|
||||
2. 前端 Dashboard 展示正确
|
||||
|
||||
### 阶段 4:混合执行模型
|
||||
1. 插件 WASM 中 db_insert 后立即 db_query → 读后写一致性
|
||||
2. 带 filter 的 db_query → 过滤结果正确
|
||||
3. 旧插件(预填充模式)仍能正常工作
|
||||
4. 多次连续 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` | 适配多聚合返回格式 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
# 汕头市智界科技 IT 服务插件 — 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为汕头市智界科技有限公司创建 freelance(自由职业者工作台)和 itops(IT 运维服务台)两个 WASM 插件,覆盖其全部 12 条经营范围。
|
||||
|
||||
**Architecture:** 两个独立的 WASM 插件 crate,每个包含 Cargo.toml(cdylib)、src/lib.rs(Guest trait 实现)、plugin.toml(声明式 schema)。通过插件安装 API 上传到系统,平台自动创建动态表、注册权限、生成前端页面。itops 通过 ref_plugin 跨插件引用 freelance 的 client 实体。
|
||||
|
||||
**Tech Stack:** Rust (wit-bindgen 0.55, cdylib → WASM Component)、TOML manifest、Axum Host API
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: freelance 插件
|
||||
|
||||
### Task 1: 创建 crate 目录和 Cargo.toml
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-freelance/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-freelance/src/lib.rs`(空文件占位)
|
||||
|
||||
- [ ] **Step 1: 创建目录结构**
|
||||
|
||||
```bash
|
||||
mkdir -p crates/erp-plugin-freelance/src
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写 Cargo.toml**
|
||||
|
||||
创建 `crates/erp-plugin-freelance/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-freelance"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "自由职业者工作台 WASM 插件"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写 src/lib.rs**
|
||||
|
||||
创建 `crates/erp-plugin-freelance/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
//! 自由职业者工作台 WASM 插件
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
|
||||
struct FreelancePlugin;
|
||||
|
||||
impl Guest for FreelancePlugin {
|
||||
fn init() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
export!(FreelancePlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 注册到 workspace**
|
||||
|
||||
编辑根 `Cargo.toml`,在 `members` 数组末尾添加:
|
||||
|
||||
```toml
|
||||
"crates/erp-plugin-freelance",
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-freelance
|
||||
```
|
||||
|
||||
Expected: 编译通过,无错误
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-freelance/ Cargo.toml
|
||||
git commit -m "feat(freelance): 创建插件 crate 骨架"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 编写 plugin.toml(freelance)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
- [ ] **Step 1: 从设计规格文档复制完整 plugin.toml 内容**
|
||||
|
||||
从设计规格 `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md` 中提取 2.1(元数据)+ 2.2(权限)+ 2.3(10 个实体)+ 2.4(编号规则)+ 2.5(页面声明)的所有 TOML 内容,合并为完整的 `plugin.toml` 文件。
|
||||
|
||||
文件结构:
|
||||
1. `[metadata]` 段
|
||||
2. `[[permissions]]` × 20
|
||||
3. `[[schema.entities]]` × 10(client, opportunity, quote, quote_line, contract, project, task, time_entry, invoice, expense),每个实体包含 fields 和 relations
|
||||
4. `[[numbering]]` × 3(quote_number, contract_number, invoice_number)
|
||||
5. `[[ui.pages]]` × 7(dashboard, tabs+detail+kanban for client, crud+detail for project, tabs for finance, crud for expense)
|
||||
|
||||
注意要点:
|
||||
- client 实体必须标记 `is_public = true`(被 itops 跨插件引用)
|
||||
- quote 到 quote_line 有 cascade 关系
|
||||
- project 到 task 和 time_entry 有 cascade 关系
|
||||
- 所有 uuid 引用字段使用 `ui_widget = "entity_select"` + `ref_label_field` + `ref_search_fields`
|
||||
- 所有 select 字段使用 `options = [{ label = "X", value = "x" }]` 格式
|
||||
- 长文本使用 `field_type = "string"` + `ui_widget = "textarea"`
|
||||
- 金额使用 `field_type = "decimal"`
|
||||
- 时间戳使用 `field_type = "date_time"`
|
||||
|
||||
- [ ] **Step 2: 验证 TOML 格式**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-freelance
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-freelance/plugin.toml
|
||||
git commit -m "feat(freelance): 添加 plugin.toml — 10 实体/20 权限/7 页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 编译 WASM 并安装
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM**
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-freelance --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
Expected: 编译成功,产出 `target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm`
|
||||
|
||||
- [ ] **Step 2: 转换为 Component**
|
||||
|
||||
```bash
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm -o target/erp_plugin_freelance.component.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 检查产物大小**
|
||||
|
||||
```bash
|
||||
ls -la target/erp_plugin_freelance.component.wasm
|
||||
```
|
||||
|
||||
Expected: < 100KB(CRM 约 22KB)
|
||||
|
||||
- [ ] **Step 4: 启动后端服务**
|
||||
|
||||
```bash
|
||||
cd crates/erp-server && cargo run
|
||||
```
|
||||
|
||||
等待服务启动完成(看到 "listening on 0.0.0.0:3000" 日志)
|
||||
|
||||
- [ ] **Step 5: 登录获取 Token**
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:3000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin123"}' | jq -r '.data.access_token'
|
||||
```
|
||||
|
||||
保存输出的 token。
|
||||
|
||||
- [ ] **Step 6: 上传安装插件**
|
||||
|
||||
```bash
|
||||
TOKEN="<上一步的 token>"
|
||||
MANIFEST=$(cat crates/erp-plugin-freelance/plugin.toml)
|
||||
|
||||
curl -s -X POST http://localhost:3000/api/v1/admin/plugins/upload \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "wasm=@target/erp_plugin_freelance.component.wasm" \
|
||||
-F "manifest=$MANIST"
|
||||
```
|
||||
|
||||
Expected: 返回插件 ID,状态为 `installed`
|
||||
|
||||
- [ ] **Step 7: 启用插件**
|
||||
|
||||
使用上一步返回的插件 ID:
|
||||
|
||||
```bash
|
||||
PLUGIN_ID="<返回的插件 ID>"
|
||||
curl -s -X POST "http://localhost:3000/api/v1/admin/plugins/$PLUGIN_ID/enable" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
Expected: 状态变为 `running`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(freelance): 编译 WASM 并验证安装"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 浏览器验证 freelance 插件
|
||||
|
||||
- [ ] **Step 1: 打开浏览器访问 http://localhost:5174**
|
||||
|
||||
- [ ] **Step 2: 登录后检查侧边栏**
|
||||
|
||||
Expected: 看到"自由职业者工作台"菜单组,包含:工作台、客户管理、商机看板、项目管理、项目详情、财务中心、支出管理
|
||||
|
||||
- [ ] **Step 3: 测试客户 CRUD**
|
||||
|
||||
进入客户管理 → 新增客户(填写名称、联系人、电话、行业等)→ 保存 → 列表中可见
|
||||
|
||||
- [ ] **Step 4: 测试项目 → 任务级联**
|
||||
|
||||
进入项目管理 → 新增项目 → 进入项目详情 → 新增任务 → 验证任务关联到项目
|
||||
|
||||
- [ ] **Step 5: 测试报价 → 报价明细级联**
|
||||
|
||||
进入财务中心 → 报价管理 tab → 新增报价 → 验证明细行可添加
|
||||
|
||||
- [ ] **Step 6: 测试商机看板**
|
||||
|
||||
进入商机看板 → 新增商机 → 拖拽改变阶段 → 验证数据更新
|
||||
|
||||
- [ ] **Step 7: 验证数据库表创建**
|
||||
|
||||
```bash
|
||||
PGPASSWORD=123123 "D:\postgreSQL\bin\psql.exe" -U postgres -h localhost -d erp -c "\dt plugin_erp_freelance_*"
|
||||
```
|
||||
|
||||
Expected: 看到 10 张动态表
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: itops 插件
|
||||
|
||||
### Task 5: 创建 itops 插件 crate
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-itops/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-itops/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
- [ ] **Step 1: 创建目录结构**
|
||||
|
||||
```bash
|
||||
mkdir -p crates/erp-plugin-itops/src
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写 Cargo.toml**
|
||||
|
||||
创建 `crates/erp-plugin-itops/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-itops"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "IT 运维服务台 WASM 插件"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写 src/lib.rs**
|
||||
|
||||
创建 `crates/erp-plugin-itops/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
//! IT 运维服务台 WASM 插件
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
|
||||
struct ItopsPlugin;
|
||||
|
||||
impl Guest for ItopsPlugin {
|
||||
fn init() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
export!(ItopsPlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编写 plugin.toml**
|
||||
|
||||
从设计规格文档 Section 3 提取完整内容:
|
||||
1. `[metadata]` — id="erp-itops",无 dependencies(松耦合)
|
||||
2. `[[permissions]]` × 8
|
||||
3. `[[schema.entities]]` × 4(service_contract, ticket, check_plan, check_record),每个实体包含 fields 和 relations
|
||||
4. `[[numbering]]` × 1(contract_number)
|
||||
5. `[[ui.pages]]` × 4(crud+detail for service_contract, tabs for ticket center)
|
||||
|
||||
关键注意点:
|
||||
- 4 个实体的 `client_id` 字段都使用 `ref_plugin = "erp-freelance"` + `ref_fallback_label = "外部客户"`
|
||||
- `filterable` 只用于 string 类型的 status/type/category 字段,不用于 uuid 字段
|
||||
- `check_items` 和 `items_data` 使用 `field_type = "json"`
|
||||
- `responded_at` / `resolved_at` / `closed_at` 使用 `field_type = "date_time"`
|
||||
|
||||
- [ ] **Step 5: 注册到 workspace**
|
||||
|
||||
编辑根 `Cargo.toml`,在 members 数组末尾添加:
|
||||
|
||||
```toml
|
||||
"crates/erp-plugin-itops",
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-itops
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-itops/ Cargo.toml
|
||||
git commit -m "feat(itops): 创建 IT 运维服务台插件 — 4 实体/8 权限/4 页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 编译 WASM 并安装 itops
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM**
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-itops --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 转换为 Component**
|
||||
|
||||
```bash
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_itops.wasm -o target/erp_plugin_itops.component.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 上传安装插件**
|
||||
|
||||
```bash
|
||||
TOKEN="<之前获取的 token>"
|
||||
MANIFEST=$(cat crates/erp-plugin-itops/plugin.toml)
|
||||
|
||||
curl -s -X POST http://localhost:3000/api/v1/admin/plugins/upload \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "wasm=@target/erp_plugin_itops.component.wasm" \
|
||||
-F "manifest=$MANIFEST"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 启用插件**
|
||||
|
||||
```bash
|
||||
PLUGIN_ID="<返回的插件 ID>"
|
||||
curl -s -X POST "http://localhost:3000/api/v1/admin/plugins/$PLUGIN_ID/enable" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 浏览器验证 itops 插件
|
||||
|
||||
- [ ] **Step 1: 检查侧边栏**
|
||||
|
||||
Expected: 看到"IT 运维服务台"菜单组,包含:合同管理、合同详情、工单中心
|
||||
|
||||
- [ ] **Step 2: 测试维保合同 CRUD**
|
||||
|
||||
进入合同管理 → 新增维保合同(选择客户时验证:如 freelance 已安装,客户下拉显示 freelance 的客户列表)
|
||||
|
||||
- [ ] **Step 3: 测试跨插件引用**
|
||||
|
||||
场景 A(freelance 已安装):创建工单时 client_id 字段显示为下拉选择器,可搜索 freelance.client
|
||||
场景 B(freelance 未安装):client_id 降级为文本输入,显示"外部客户"
|
||||
|
||||
- [ ] **Step 4: 测试合同 → 工单 → 巡检级联**
|
||||
|
||||
进入合同详情 → 工单 tab → 新增工单 → 巡检计划 tab → 新增巡检计划 → 巡检记录 tab → 新增巡检记录
|
||||
|
||||
- [ ] **Step 5: 验证数据库表**
|
||||
|
||||
```bash
|
||||
PGPASSWORD=123123 "D:\postgreSQL\bin\psql.exe" -U postgres -h localhost -d erp -c "\dt plugin_erp_itops_*"
|
||||
```
|
||||
|
||||
Expected: 看到 4 张动态表
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 集成验证
|
||||
|
||||
### Task 8: 全链路端到端验证
|
||||
|
||||
- [ ] **Step 1: 创建客户**
|
||||
|
||||
freelance → 客户管理 → 新增客户"汕头市XX科技有限公司"
|
||||
|
||||
- [ ] **Step 2: 创建商机**
|
||||
|
||||
商机看板 → 新增商机 → 选择客户 → 填写"官网开发"→ 拖拽到"成交"阶段
|
||||
|
||||
- [ ] **Step 3: 创建报价单**
|
||||
|
||||
财务中心 → 报价管理 → 新增报价 → 选择客户 → 添加明细行 → 保存
|
||||
|
||||
- [ ] **Step 4: 创建合同**
|
||||
|
||||
财务中心 → 合同管理 → 新增合同 → 选择客户 → 填写金额和日期 → 保存
|
||||
|
||||
- [ ] **Step 5: 创建项目**
|
||||
|
||||
项目管理 → 新增项目 → 选择客户和合同 → 填写"官网开发项目" → 添加任务 → 记录工时
|
||||
|
||||
- [ ] **Step 6: 创建发票**
|
||||
|
||||
财务中心 → 发票/收款 → 新增发票 → 选择客户和项目 → 填写金额 → 标记已收款
|
||||
|
||||
- [ ] **Step 7: 创建运维工单**
|
||||
|
||||
itops → 合同管理 → 新增维保合同 → 选择客户(验证跨插件引用)→ 保存
|
||||
itops → 工单中心 → 新增工单 → 选择客户和合同 → 保存
|
||||
|
||||
- [ ] **Step 8: 记录支出**
|
||||
|
||||
freelance → 支出管理 → 新增支出 → 选择类别"云服务" → 填写金额 → 保存
|
||||
|
||||
- [ ] **Step 9: 提交并推送**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(freelance,itops): 汕头市智界科技 IT 服务行业插件验证通过"
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键参考文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `crates/erp-plugin-crm/Cargo.toml` | Cargo.toml 模板参考 |
|
||||
| `crates/erp-plugin-crm/src/lib.rs` | lib.rs 代码模式参考 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | plugin.toml 格式参考(同插件内引用) |
|
||||
| `crates/erp-plugin-inventory/plugin.toml` | 跨插件引用格式参考(ref_plugin) |
|
||||
| `crates/erp-plugin/src/manifest.rs` | PluginField/PluginFieldType 完整定义 |
|
||||
| `crates/erp-plugin-prototype/wit/plugin.wit` | WIT 接口定义 |
|
||||
| `wiki/infrastructure.md` | 数据库连接、端口、登录凭据 |
|
||||
| `wiki/wasm-plugin.md` | 插件制作完整流程 |
|
||||
@@ -0,0 +1,624 @@
|
||||
# freelance + itops 插件增强设计规格
|
||||
|
||||
> 日期: 2026-04-20
|
||||
> 来源: 多专家头脑风暴(UX专家 + 业务顾问 + 运维专家 + 财务专家)
|
||||
> 状态: Draft
|
||||
> 前置: `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
当前插件是「数据录入系统」,不是「赚钱工具」。一人 IT 服务公司的核心痛点:
|
||||
|
||||
1. **钱从哪里来?** — 商机跟进靠人记,没有自动提醒、没有漏斗分析
|
||||
2. **项目做到哪了?** — 任务状态和工时手动填,跟合同金额/应收款脱节
|
||||
3. **钱收回来了吗?** — 报价→合同→开票→收款割裂,没有串联
|
||||
4. **运维服务会不会忘?** — 巡检计划写了没人催,SLA 超时了才知道
|
||||
5. **税和利润算不清?** — 收支分散在不同表里,月底做账要手动汇总
|
||||
|
||||
**问题根因:** 平台已有 trigger_events、settings、templates、cascade_from、visible_when、validation 六大能力,但两个插件完全没有使用。
|
||||
|
||||
**改进目标:** 纯插件层增强,三层递进:
|
||||
- Layer 1: 智能业务引擎 — 让系统主动驱动用户做事
|
||||
- Layer 2: 仪表盘重构 — 一个页面掌控全局
|
||||
- Layer 3: 专业输出 — 一键生成报价单/发票/合同 PDF
|
||||
|
||||
---
|
||||
|
||||
## 2. Layer 1: 智能业务引擎 — freelance 插件
|
||||
|
||||
### 2.1 Settings(插件配置页)
|
||||
|
||||
一次性配置公司信息和业务偏好,后续自动生效:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
# ── 基本信息 ──
|
||||
[[settings.fields]]
|
||||
name = "company_name"
|
||||
display_name = "公司名称"
|
||||
field_type = "text"
|
||||
required = true
|
||||
group = "基本信息"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "currency_symbol"
|
||||
display_name = "货币符号"
|
||||
field_type = "text"
|
||||
default_value = "¥"
|
||||
group = "基本信息"
|
||||
|
||||
# ── 财务 ──
|
||||
[[settings.fields]]
|
||||
name = "default_tax_rate"
|
||||
display_name = "默认税率(%)"
|
||||
field_type = "number"
|
||||
default_value = 6
|
||||
range = [0.0, 100.0]
|
||||
group = "财务"
|
||||
|
||||
# ── 提醒 ──
|
||||
[[settings.fields]]
|
||||
name = "payment_reminder_days"
|
||||
display_name = "收款提前提醒(天)"
|
||||
field_type = "number"
|
||||
default_value = 3
|
||||
range = [1.0, 30.0]
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_contract_expiring"
|
||||
display_name = "合同到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_payment_overdue"
|
||||
display_name = "逾期收款提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_opportunity_followup"
|
||||
display_name = "商机跟进提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### 2.2 Trigger Events(自动事件驱动)
|
||||
|
||||
关键操作时自动发通知,把"人找事"变"事找人":
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "opportunity_stage_changed"
|
||||
display_name = "商机阶段变更"
|
||||
description = "商机阶段发生变化时通知,特别是成交或失败"
|
||||
entity = "opportunity"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "invoice_status_changed"
|
||||
display_name = "发票状态变更"
|
||||
description = "发票状态变化时检查逾期收款"
|
||||
entity = "invoice"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "task_status_changed"
|
||||
display_name = "任务状态变更"
|
||||
description = "任务完成或取消时通知"
|
||||
entity = "task"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "expense_created"
|
||||
display_name = "新支出记录"
|
||||
description = "记录新支出时通知"
|
||||
entity = "expense"
|
||||
on = "create"
|
||||
```
|
||||
|
||||
### 2.3 Cascade(智能联动下拉)
|
||||
|
||||
选客户后自动过滤其关联数据。以下均为**已有字段追加 cascade 属性**,不是新增字段:
|
||||
|
||||
**contract 实体 — 已有 opportunity_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**contract 实体 — 已有 quote_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**invoice 实体 — 已有 project_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**invoice 实体 — 已有 contract_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**time_entry 实体 — 已有 task_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "project_id"
|
||||
cascade_filter = "project_id"
|
||||
```
|
||||
|
||||
### 2.4 Visible When(条件显示)
|
||||
|
||||
只在有意义时才显示字段。以下为**已有字段追加 visible_when 属性**:
|
||||
|
||||
**invoice 实体 — 已有 payment_date 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'paid' || status == 'partial'"
|
||||
```
|
||||
|
||||
**contract 实体 — 已有 paid_amount 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'drafting'"
|
||||
```
|
||||
|
||||
**task 实体 — 已有 actual_hours 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'todo'"
|
||||
```
|
||||
|
||||
**quote 实体 — 已有 total_amount 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'draft'"
|
||||
```
|
||||
|
||||
### 2.5 Validation(字段校验)
|
||||
|
||||
**已有字段追加 validation 属性**,不是新增字段:
|
||||
|
||||
**client 实体 — 已有 email 字段追加:**
|
||||
```toml
|
||||
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
|
||||
```
|
||||
|
||||
**client 实体 — 已有 phone 字段追加:**
|
||||
```toml
|
||||
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Layer 2: 仪表盘重构 — freelance 插件
|
||||
|
||||
将占位符仪表盘升级为真正的指挥中心。通过 `widgets` 声明告诉平台该展示什么。
|
||||
|
||||
> **平台依赖:** 仪表盘 widgets 需要平台层配合:
|
||||
> 1. `manifest.rs` 的 `PluginPageType::Dashboard` 需要新增 `widgets: Option<Vec<PluginWidget>>` 字段
|
||||
> 2. 定义 `PluginWidget` 枚举(stat_cards/action_list/funnel/card_list 类型)
|
||||
> 3. 更新 TOML 解析和验证逻辑
|
||||
> 4. 前端解析 `widgets` 声明并渲染对应组件
|
||||
>
|
||||
> 因此 P5/P6 **不是纯 plugin.toml 改动**,需要平台+前端联合实施。以下 widgets 声明作为设计参考,实施时需先完成平台侧支持。
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "工作台"
|
||||
icon = "DashboardOutlined"
|
||||
|
||||
# ── 财务概览卡片 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "stat_cards"
|
||||
label = "财务概览"
|
||||
cards = [
|
||||
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "type == 'payment' && status != 'overdue'", label = "本月收入", icon = "rise", color = "green" },
|
||||
{ entity = "expense", aggregate = "sum", field = "amount", label = "本月支出", icon = "fall", color = "red" },
|
||||
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "status == 'overdue' || status == 'pending'", label = "应收总额", icon = "dollar", color = "orange" },
|
||||
{ entity = "invoice", aggregate = "count", filter = "status == 'overdue'", label = "逾期笔数", icon = "warning", color = "red" }
|
||||
]
|
||||
|
||||
# ── 紧急待办 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "action_list"
|
||||
label = "紧急待办"
|
||||
max_items = 5
|
||||
queries = [
|
||||
{ entity = "invoice", filter = "status == 'overdue'", label_field = "invoice_number", subtitle_field = "amount", action = "查看", icon = "warning" },
|
||||
{ entity = "task", filter = "status != 'done' && status != 'cancelled'", sort = "due_date asc", label_field = "title", subtitle_field = "due_date", action = "处理", icon = "clock" },
|
||||
{ entity = "contract", filter = "status == 'active'", sort = "end_date asc", label_field = "title", subtitle_field = "end_date", action = "续约", icon = "file-text" },
|
||||
{ entity = "opportunity", filter = "next_follow_up <= today", label_field = "title", subtitle_field = "next_follow_up", action = "跟进", icon = "phone" }
|
||||
]
|
||||
|
||||
# ── 商机漏斗 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "funnel"
|
||||
label = "商机漏斗"
|
||||
entity = "opportunity"
|
||||
lane_field = "stage"
|
||||
value_field = "estimated_amount"
|
||||
lane_order = ["visit", "requirement", "quote", "negotiation", "won", "lost"]
|
||||
|
||||
# ── 活跃项目卡片 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "card_list"
|
||||
label = "活跃项目"
|
||||
entity = "project"
|
||||
filter = "status == 'in_progress'"
|
||||
max_items = 4
|
||||
title_field = "name"
|
||||
subtitle_field = "contract_amount"
|
||||
tags = ["business_type", "status"]
|
||||
```
|
||||
|
||||
**依赖:** 数据源来自平台已有的聚合 API(`/count`、`/aggregate`)。Filter 表达式使用平台过滤 DSL(`==`, `!=`, `||`, `&&`, `<=`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. Layer 3: 专业输出 — freelance 插件
|
||||
|
||||
一键生成专业 PDF,替代手动排 Word。
|
||||
|
||||
> **模板引擎说明:**
|
||||
> - 语法基于 Handlebars(`{{field}}`, `{{#each relation}}...{{/each}}`)
|
||||
> - 当前实体字段直接可用:`{{amount}}`, `{{status}}`
|
||||
> - 关系字段解析:`{{client.name}}` 表示通过 `client_id` 引用的 client 实体的 name 字段,渲染器需自动解析
|
||||
> - `{{#each lines}}` 用于一对多关系(如 quote → quote_line),渲染器查询子实体并遍历
|
||||
> - 平台需要实现 PDF 渲染管道:TOML 模板 → Handlebars 渲染(注入数据)→ HTML → wkhtmltopdf/浏览器打印 → PDF
|
||||
|
||||
### 4.1 报价单模板
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "quote_pdf"
|
||||
display_name = "报价单"
|
||||
entity = "quote"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f5f5f5; }
|
||||
.total { text-align: right; font-size: 18px; font-weight: bold; }
|
||||
.footer { margin-top: 40px; color: #666; font-size: 12px; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>报价单 {{quote_number}}</h1>
|
||||
<p>客户:{{client.name}} | 有效期至:{{valid_until}}</p>
|
||||
<table>
|
||||
<tr><th>项目</th><th>描述</th><th>数量</th><th>单价</th><th>金额</th></tr>
|
||||
{{#each lines}}
|
||||
<tr><td>{{item_name}}</td><td>{{description}}</td><td>{{quantity}}</td><td>{{unit_price}}</td><td>{{amount}}</td></tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
<p class="total">小计:{{subtotal}} | 税率:{{tax_rate}}% | 总计:{{total_amount}}</p>
|
||||
<div class="footer">备注:{{notes}}</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
### 4.2 发票模板
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "invoice_pdf"
|
||||
display_name = "发票"
|
||||
entity = "invoice"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; color: #1890ff; border-bottom: 2px solid #1890ff; padding-bottom: 10px; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
|
||||
.info-item { padding: 8px; background: #fafafa; }
|
||||
.amount { font-size: 24px; font-weight: bold; text-align: center; color: #f5222d; margin: 20px 0; }
|
||||
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 4px; background: #f0f0f0; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>发票 {{invoice_number}}</h1>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">客户:{{client.name}}</div>
|
||||
<div class="info-item">类型:{{type}}</div>
|
||||
<div class="info-item">开票日期:{{issue_date}}</div>
|
||||
<div class="info-item">到期日:{{due_date}}</div>
|
||||
</div>
|
||||
<div class="amount">¥{{amount}}</div>
|
||||
<p>状态:<span class="status-badge">{{status}}</span></p>
|
||||
<p>备注:{{notes}}</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
### 4.3 合同模板
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "contract_pdf"
|
||||
display_name = "合同"
|
||||
entity = "contract"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; border-bottom: 3px double #333; padding-bottom: 10px; }
|
||||
.parties { margin: 20px 0; padding: 15px; background: #fafafa; border-left: 4px solid #1890ff; }
|
||||
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
|
||||
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>{{title}}</h1>
|
||||
<p>合同编号:{{contract_number}}</p>
|
||||
<div class="parties">
|
||||
<p>甲方:{{client.name}}</p>
|
||||
<p>合同金额:¥{{amount}} | 已付:¥{{paid_amount}}</p>
|
||||
<p>期限:{{start_date}} 至 {{end_date}}</p>
|
||||
<p>付款条款:{{payment_terms}}</p>
|
||||
</div>
|
||||
<p>备注:{{notes}}</p>
|
||||
<div class="signature">
|
||||
<div class="sig-box">甲方签章</div>
|
||||
<div class="sig-box">乙方签章</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. itops 插件增强
|
||||
|
||||
### 5.1 Settings
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_response"
|
||||
display_name = "默认SLA响应时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 8
|
||||
range = [1.0, 72.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_resolve"
|
||||
display_name = "默认SLA解决时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 48
|
||||
range = [1.0, 168.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_sla_breach"
|
||||
display_name = "SLA超标提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_check_due"
|
||||
display_name = "巡检到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### 5.2 Trigger Events
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "ticket_created"
|
||||
display_name = "新工单"
|
||||
description = "创建工单时开始SLA计时并通知"
|
||||
entity = "ticket"
|
||||
on = "create"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "ticket_status_changed"
|
||||
display_name = "工单状态变更"
|
||||
description = "工单状态变化时检查SLA是否达标"
|
||||
entity = "ticket"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "维保合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "service_contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "check_plan_updated"
|
||||
display_name = "巡检计划更新"
|
||||
description = "巡检计划更新时检查下次巡检日期"
|
||||
entity = "check_plan"
|
||||
on = "update"
|
||||
```
|
||||
|
||||
### 5.3 Cascade
|
||||
|
||||
**已有字段追加 cascade 属性**,不是新增字段:
|
||||
|
||||
**ticket 实体 — 已有 contract_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**check_record 实体 — 已有 contract_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "plan_id"
|
||||
cascade_filter = "contract_id"
|
||||
```
|
||||
|
||||
### 5.4 Visible When
|
||||
|
||||
**已有字段追加 visible_when 属性**:
|
||||
|
||||
**ticket 实体 — 已有 resolution 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'resolved' || status == 'closed'"
|
||||
```
|
||||
|
||||
**ticket 实体 — 已有 responded_at 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'open'"
|
||||
```
|
||||
|
||||
**ticket 实体 — 已有 resolved_at 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'resolved' || status == 'closed'"
|
||||
```
|
||||
|
||||
**ticket 实体 — 已有 closed_at 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'closed'"
|
||||
```
|
||||
|
||||
**check_record 实体 — 已有 issues_found 字段追加:**
|
||||
```toml
|
||||
visible_when = "result == 'abnormal'"
|
||||
```
|
||||
|
||||
**check_record 实体 — 已有 actions_taken 字段追加:**
|
||||
```toml
|
||||
visible_when = "result == 'abnormal'"
|
||||
```
|
||||
|
||||
### 5.5 Validation
|
||||
|
||||
**已有字段追加 validation 属性**:
|
||||
|
||||
**service_contract 实体 — 已有 contract_number 字段追加:**
|
||||
```toml
|
||||
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式:SC-YYYY-NNNN" }
|
||||
```
|
||||
|
||||
### 5.6 Dashboard
|
||||
|
||||
> **同 Layer 2 说明:** widgets 需要平台层配合(manifest.rs 扩展 + 前端渲染),非纯 plugin.toml 改动。此仪表盘页面**插入到现有页面列表最前面**,现有 4 个页面保持不变。
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "运维概览"
|
||||
icon = "DashboardOutlined"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "stat_cards"
|
||||
label = "运维概览"
|
||||
cards = [
|
||||
{ entity = "service_contract", aggregate = "count", filter = "status == 'active'", label = "活跃合同", icon = "file-text", color = "blue" },
|
||||
{ entity = "ticket", aggregate = "count", filter = "status == 'open' || status == 'in_progress'", label = "待处理工单", icon = "tool", color = "orange" },
|
||||
{ entity = "ticket", aggregate = "count", filter = "status == 'resolved'", label = "已解决工单", icon = "check-circle", color = "green" },
|
||||
{ entity = "check_plan", aggregate = "count", filter = "status == 'active'", label = "活跃巡检", icon = "schedule", color = "blue" }
|
||||
]
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "action_list"
|
||||
label = "紧急待办"
|
||||
max_items = 5
|
||||
queries = [
|
||||
{ entity = "ticket", filter = "status == 'open'", sort = "priority asc", label_field = "title", subtitle_field = "type", action = "处理", icon = "warning" },
|
||||
{ entity = "service_contract", filter = "status == 'active'", sort = "end_date asc", label_field = "name", subtitle_field = "end_date", action = "续约", icon = "file-text" },
|
||||
{ entity = "check_plan", filter = "status == 'active'", sort = "next_check_date asc", label_field = "name", subtitle_field = "next_check_date", action = "巡检", icon = "schedule" }
|
||||
]
|
||||
```
|
||||
|
||||
### 5.7 Template(维保合同 PDF)
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "service_contract_pdf"
|
||||
display_name = "维保合同"
|
||||
entity = "service_contract"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; border-bottom: 3px double #1890ff; padding-bottom: 10px; color: #1890ff; }
|
||||
.sla-box { margin: 20px 0; padding: 15px; background: #e6f7ff; border: 1px solid #91d5ff; border-radius: 4px; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
|
||||
.info-item { padding: 8px; background: #fafafa; }
|
||||
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
|
||||
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>{{name}}</h1>
|
||||
<p>合同编号:{{contract_number}}</p>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">客户:{{client.name}}</div>
|
||||
<div class="info-item">合同金额:¥{{amount}}</div>
|
||||
<div class="info-item">期限:{{start_date}} 至 {{end_date}}</div>
|
||||
<div class="info-item">状态:{{status}}</div>
|
||||
</div>
|
||||
<div class="sla-box">
|
||||
<strong>SLA 承诺:</strong>响应 {{sla_response_hours}} 小时内 / 解决 {{sla_resolve_hours}} 小时内
|
||||
</div>
|
||||
<p>服务范围:{{service_scope}}</p>
|
||||
<p>付款条款:{{payment_terms}}</p>
|
||||
<p>备注:{{notes}}</p>
|
||||
<div class="signature">
|
||||
<div class="sig-box">甲方签章</div>
|
||||
<div class="sig-box">乙方签章</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 改进汇总
|
||||
|
||||
| 层次 | 能力 | freelance | itops |
|
||||
|------|------|-----------|-------|
|
||||
| Layer 1 | settings | 7 个配置项(公司名/税率/提醒偏好) | 4 个配置项(SLA默认值/提醒偏好) |
|
||||
| Layer 1 | trigger_events | 5 个事件(商机/合同/发票/任务/支出) | 4 个事件(工单/合同/巡检) |
|
||||
| Layer 1 | cascade | 4 处联动(合同/发票/工时表单) | 2 处联动(工单/巡检记录) |
|
||||
| Layer 1 | visible_when | 4 个条件字段 | 6 个条件字段 |
|
||||
| Layer 1 | validation | 2 个校验(邮箱/手机) | 1 个校验(合同编号格式) |
|
||||
| Layer 2 | dashboard widgets | 财务卡片+紧急待办+商机漏斗+项目卡片 | 运维卡片+紧急待办 |
|
||||
| Layer 3 | templates | 3 个 PDF(报价单/发票/合同) | 1 个 PDF(维保合同) |
|
||||
|
||||
**总计:** 2 个插件 × 3 层增强,从「数据录入」升级为「赚钱工具」。
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施优先级
|
||||
|
||||
```
|
||||
P1: freelance Layer 1(settings + trigger_events + cascade + visible_when + validation)
|
||||
P2: itops Layer 1(settings + trigger_events + cascade + visible_when + validation)
|
||||
P3: freelance Layer 3(3 个 PDF 模板)
|
||||
P4: itops Layer 3(维保合同 PDF 模板)
|
||||
P5: freelance Layer 2(仪表盘 widgets)
|
||||
P6: itops Layer 2(仪表盘 widgets)
|
||||
```
|
||||
|
||||
P1-P4 是纯 plugin.toml 改动(给已有字段追加 cascade/visible_when/validation 属性,以及新增 settings/trigger_events/templates 段落),可立即实施。P5-P6 的仪表盘 widgets 需要平台层配合:扩展 `manifest.rs` 的 `PluginPageType::Dashboard` 支持 `widgets` 字段 + 前端渲染组件。
|
||||
@@ -0,0 +1,587 @@
|
||||
# freelance + itops 插件增强实施计划
|
||||
|
||||
> 日期: 2026-04-20
|
||||
> 对应规格: `docs/superpowers/specs/2026-04-20-freelance-itops-plugin-enhancement-design.md`
|
||||
> 前置: 两插件已部署(freelance 10 实体/20 权限,itops 4 实体/8 权限)
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| Phase | 内容 | 类型 | 依赖 |
|
||||
|-------|------|------|------|
|
||||
| P1 | freelance Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
|
||||
| P2 | itops Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
|
||||
| P3 | freelance Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
|
||||
| P4 | itops Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
|
||||
| P5 | 平台 dashboard widgets 扩展 | manifest.rs + 前端 | P1-P4 完成 |
|
||||
| P6 | freelance + itops Layer 2 — 仪表盘 | plugin.toml + 前端 | P5 完成 |
|
||||
|
||||
P1-P4 可并行,P5-P6 顺序依赖。
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: freelance Layer 1 — 智能业务引擎
|
||||
|
||||
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
### Task 1.1: 新增 `[settings]` 段落
|
||||
|
||||
在 `[[numbering]]` 之前插入 7 个配置项:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
[[settings.fields]]
|
||||
name = "company_name"
|
||||
display_name = "公司名称"
|
||||
field_type = "text"
|
||||
required = true
|
||||
group = "基本信息"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "currency_symbol"
|
||||
display_name = "货币符号"
|
||||
field_type = "text"
|
||||
default_value = "¥"
|
||||
group = "基本信息"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_tax_rate"
|
||||
display_name = "默认税率(%)"
|
||||
field_type = "number"
|
||||
default_value = 6
|
||||
range = [0.0, 100.0]
|
||||
group = "财务"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "payment_reminder_days"
|
||||
display_name = "收款提前提醒(天)"
|
||||
field_type = "number"
|
||||
default_value = 3
|
||||
range = [1.0, 30.0]
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_contract_expiring"
|
||||
display_name = "合同到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_payment_overdue"
|
||||
display_name = "逾期收款提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_opportunity_followup"
|
||||
display_name = "商机跟进提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### Task 1.2: 新增 `[[trigger_events]]` 段落
|
||||
|
||||
在 `[settings]` 之后插入 5 个触发事件:
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "opportunity_stage_changed"
|
||||
display_name = "商机阶段变更"
|
||||
description = "商机阶段发生变化时通知,特别是成交或失败"
|
||||
entity = "opportunity"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "invoice_status_changed"
|
||||
display_name = "发票状态变更"
|
||||
description = "发票状态变化时检查逾期收款"
|
||||
entity = "invoice"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "task_status_changed"
|
||||
display_name = "任务状态变更"
|
||||
description = "任务完成或取消时通知"
|
||||
entity = "task"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "expense_created"
|
||||
display_name = "新支出记录"
|
||||
description = "记录新支出时通知"
|
||||
entity = "expense"
|
||||
on = "create"
|
||||
```
|
||||
|
||||
### Task 1.3: 追加 cascade 属性(5 处已有字段)
|
||||
|
||||
**1.3a** contract.opportunity_id(第 450-454 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3b** contract.quote_id(第 456-460 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3c** invoice.project_id(第 796-800 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3d** invoice.contract_id(第 802-806 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3e** time_entry.task_id(第 745-751 行)追加:
|
||||
```toml
|
||||
cascade_from = "project_id"
|
||||
cascade_filter = "project_id"
|
||||
```
|
||||
|
||||
### Task 1.4: 追加 visible_when 属性(4 处已有字段)
|
||||
|
||||
**1.4a** invoice.payment_date(第 860-863 行)追加:
|
||||
```toml
|
||||
visible_when = "status == 'paid' || status == 'partial'"
|
||||
```
|
||||
|
||||
**1.4b** contract.paid_amount(第 516-520 行)追加:
|
||||
```toml
|
||||
visible_when = "status != 'drafting'"
|
||||
```
|
||||
|
||||
**1.4c** task.actual_hours(第 727-730 行)追加:
|
||||
```toml
|
||||
visible_when = "status != 'todo'"
|
||||
```
|
||||
|
||||
**1.4d** quote.total_amount(第 357-361 行)追加:
|
||||
```toml
|
||||
visible_when = "status != 'draft'"
|
||||
```
|
||||
|
||||
### Task 1.5: 追加 validation 属性(2 处已有字段)
|
||||
|
||||
**1.5a** client.phone(第 135-138 行)追加:
|
||||
```toml
|
||||
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
|
||||
```
|
||||
|
||||
**1.5b** client.email(第 140-143 行)追加:
|
||||
```toml
|
||||
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
|
||||
```
|
||||
|
||||
### Task 1.6: 编译 WASM + 升级插件
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-freelance --target wasm32-unknown-unknown --release
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm -o target/erp_plugin_freelance.component.wasm
|
||||
```
|
||||
|
||||
通过 API 升级:
|
||||
```bash
|
||||
# 上传新版本 WASM
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/{plugin_id}/upgrade \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-F "wasm=@target/erp_plugin_freelance.component.wasm" \
|
||||
-F "manifest=@crates/erp-plugin-freelance/plugin.toml"
|
||||
```
|
||||
|
||||
### Task 1.7: 验证
|
||||
|
||||
- [ ] `cargo check` 通过
|
||||
- [ ] 重新登录获取新 JWT(权限可能变化)
|
||||
- [ ] 前端打开 freelance 插件 → 设置页面可见 7 个配置项
|
||||
- [ ] 创建客户 → phone 格式错误时提示校验信息
|
||||
- [ ] 创建客户 → email 格式错误时提示校验信息
|
||||
- [ ] 创建合同 → 选客户后 opportunity_id 和 quote_id 自动过滤
|
||||
- [ ] 创建发票 → 选客户后 project_id 和 contract_id 自动过滤
|
||||
- [ ] 创建工时 → 选项目后 task_id 自动过滤
|
||||
- [ ] invoice 状态为 pending 时,payment_date 字段不显示
|
||||
- [ ] contract 状态为 drafting 时,paid_amount 字段不显示
|
||||
- [ ] 触发事件:更新商机阶段 → 消息中心收到通知
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: itops Layer 1 — 智能业务引擎
|
||||
|
||||
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
### Task 2.1: 新增 `[settings]` 段落
|
||||
|
||||
在 `[[numbering]]` 之前插入 4 个配置项:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_response"
|
||||
display_name = "默认SLA响应时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 8
|
||||
range = [1.0, 72.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_resolve"
|
||||
display_name = "默认SLA解决时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 48
|
||||
range = [1.0, 168.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_sla_breach"
|
||||
display_name = "SLA超标提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_check_due"
|
||||
display_name = "巡检到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### Task 2.2: 新增 `[[trigger_events]]` 段落
|
||||
|
||||
在 `[settings]` 之后插入 4 个触发事件:
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "ticket_created"
|
||||
display_name = "新工单"
|
||||
description = "创建工单时开始SLA计时并通知"
|
||||
entity = "ticket"
|
||||
on = "create"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "ticket_status_changed"
|
||||
display_name = "工单状态变更"
|
||||
description = "工单状态变化时检查SLA是否达标"
|
||||
entity = "ticket"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "维保合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "service_contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "check_plan_updated"
|
||||
display_name = "巡检计划更新"
|
||||
description = "巡检计划更新时检查下次巡检日期"
|
||||
entity = "check_plan"
|
||||
on = "update"
|
||||
```
|
||||
|
||||
### Task 2.3: 追加 cascade 属性(2 处已有字段)
|
||||
|
||||
**2.3a** ticket.contract_id(第 186-192 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**2.3b** check_record.contract_id(第 398-400 行)追加:
|
||||
```toml
|
||||
cascade_from = "plan_id"
|
||||
cascade_filter = "contract_id"
|
||||
```
|
||||
|
||||
### Task 2.4: 追加 visible_when 属性(6 处已有字段)
|
||||
|
||||
**2.4a** ticket.resolution → `visible_when = "status == 'resolved' || status == 'closed'"`
|
||||
**2.4b** ticket.responded_at → `visible_when = "status != 'open'"`
|
||||
**2.4c** ticket.resolved_at → `visible_when = "status == 'resolved' || status == 'closed'"`
|
||||
**2.4d** ticket.closed_at → `visible_when = "status == 'closed'"`
|
||||
**2.4e** check_record.issues_found → `visible_when = "result == 'abnormal'"`
|
||||
**2.4f** check_record.actions_taken → `visible_when = "result == 'abnormal'"`
|
||||
|
||||
### Task 2.5: 追加 validation 属性(1 处已有字段)
|
||||
|
||||
**2.5a** service_contract.contract_number(第 73-78 行)追加:
|
||||
```toml
|
||||
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式:SC-YYYY-NNNN" }
|
||||
```
|
||||
|
||||
### Task 2.6: 编译 WASM + 升级插件
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-itops --target wasm32-unknown-unknown --release
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_itops.wasm -o target/erp_plugin_itops.component.wasm
|
||||
```
|
||||
|
||||
### Task 2.7: 验证
|
||||
|
||||
- [ ] `cargo check` 通过
|
||||
- [ ] 重新登录获取新 JWT
|
||||
- [ ] 前端打开 itops 插件 → 设置页面可见 4 个配置项
|
||||
- [ ] 创建工单 → 选客户后 contract_id 自动过滤
|
||||
- [ ] 工单状态为 open 时,resolution/resolved_at/closed_at 不显示
|
||||
- [ ] 工单状态改为 resolved → resolution 和 resolved_at 出现
|
||||
- [ ] 巡检记录结果为 normal → issues_found/actions_taken 不显示
|
||||
- [ ] 巡检记录结果改为 abnormal → issues_found/actions_taken 出现
|
||||
- [ ] 触发事件:创建工单 → 消息中心收到通知
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: freelance Layer 3 — PDF 模板
|
||||
|
||||
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
### Task 3.1: 新增 `[[templates]]` 段落(3 个模板)
|
||||
|
||||
在 `[[ui.pages]]` 之前插入报价单、发票、合同 3 个 PDF 模板。
|
||||
|
||||
**报价单模板** (`quote_pdf`):
|
||||
- entity = "quote"
|
||||
- 包含 Handlebars 语法:`{{quote_number}}`, `{{client.name}}`, `{{#each lines}}`
|
||||
- 表格渲染:item_name / description / quantity / unit_price / amount
|
||||
- 底部:subtotal / tax_rate / total_amount
|
||||
|
||||
**发票模板** (`invoice_pdf`):
|
||||
- entity = "invoice"
|
||||
- grid 布局:client.name / type / issue_date / due_date
|
||||
- 大字金额:`¥{{amount}}`
|
||||
- 状态 badge
|
||||
|
||||
**合同模板** (`contract_pdf`):
|
||||
- entity = "contract"
|
||||
- 签章区域:甲方/乙方
|
||||
- parties 区块:client.name / amount / paid_amount / 期限 / payment_terms
|
||||
|
||||
### Task 3.2: 编译 WASM + 升级
|
||||
|
||||
同 Task 1.6 流程。
|
||||
|
||||
### Task 3.3: 验证
|
||||
|
||||
- [ ] 前端打开报价单详情 → 可见"生成 PDF"按钮
|
||||
- [ ] 点击生成 → 下载 PDF,内容包含正确的字段值
|
||||
- [ ] 发票 PDF → 金额/客户名正确
|
||||
- [ ] 合同 PDF → 签章区域正确
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: itops Layer 3 — 维保合同 PDF 模板
|
||||
|
||||
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
### Task 4.1: 新增 `[[templates]]` 段落(1 个模板)
|
||||
|
||||
维保合同模板 (`service_contract_pdf`):
|
||||
- entity = "service_contract"
|
||||
- SLA 承诺框:响应/解决时间
|
||||
- grid 布局:client.name / amount / 期限 / status
|
||||
- 服务范围 / 付款条款 / 签章区
|
||||
|
||||
### Task 4.2: 编译 WASM + 升级
|
||||
|
||||
同 Task 2.6 流程。
|
||||
|
||||
### Task 4.3: 验证
|
||||
|
||||
- [ ] 前端打开维保合同详情 → 可见"生成 PDF"按钮
|
||||
- [ ] 点击生成 → 下载 PDF,SLA 承诺正确
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 平台 dashboard widgets 扩展
|
||||
|
||||
> **注意:** 此阶段需要修改平台 Rust 代码 + 前端代码,不是纯 plugin.toml 改动。
|
||||
|
||||
### Task 5.1: 扩展 manifest.rs — 定义 PluginWidget 类型
|
||||
|
||||
**目标文件:** `crates/erp-plugin/src/manifest.rs`
|
||||
|
||||
在 `PluginPageType::Dashboard` 结构体中新增 `widgets` 字段:
|
||||
|
||||
```rust
|
||||
// PluginPageType::Dashboard 新增字段
|
||||
Dashboard {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default)]
|
||||
widgets: Option<Vec<PluginWidget>>, // 新增
|
||||
},
|
||||
```
|
||||
|
||||
定义 `PluginWidget` 枚举及其子类型:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PluginWidget {
|
||||
StatCards {
|
||||
label: String,
|
||||
cards: Vec<StatCard>,
|
||||
},
|
||||
ActionList {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
max_items: Option<u32>,
|
||||
queries: Vec<ActionQuery>,
|
||||
},
|
||||
Funnel {
|
||||
label: String,
|
||||
entity: String,
|
||||
lane_field: String,
|
||||
#[serde(default)]
|
||||
value_field: Option<String>,
|
||||
lane_order: Vec<String>,
|
||||
},
|
||||
CardList {
|
||||
label: String,
|
||||
entity: String,
|
||||
#[serde(default)]
|
||||
filter: Option<String>,
|
||||
#[serde(default)]
|
||||
max_items: Option<u32>,
|
||||
title_field: String,
|
||||
#[serde(default)]
|
||||
subtitle_field: Option<String>,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatCard {
|
||||
pub entity: String,
|
||||
#[serde(default)]
|
||||
pub aggregate: Option<String>, // count, sum
|
||||
#[serde(default)]
|
||||
pub field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionQuery {
|
||||
pub entity: String,
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
pub label_field: String,
|
||||
#[serde(default)]
|
||||
pub subtitle_field: Option<String>,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### Task 5.2: 扩展插件 API — 返回 widgets 数据
|
||||
|
||||
**目标文件:** `crates/erp-plugin/src/module.rs`
|
||||
|
||||
新增 API 端点,为 dashboard widgets 提供数据:
|
||||
|
||||
- `GET /api/v1/plugins/{plugin_id}/dashboard/widgets` — 返回 widgets 定义
|
||||
- `GET /api/v1/plugins/{plugin_id}/dashboard/data` — 返回 widgets 聚合数据(调用已有 count/aggregate API)
|
||||
|
||||
### Task 5.3: 前端渲染 dashboard widgets
|
||||
|
||||
**目标目录:** `apps/web/src/`
|
||||
|
||||
新增组件:
|
||||
- `PluginDashboard.tsx` — 仪表盘容器,读取 widgets 定义并渲染
|
||||
- `StatCardsWidget.tsx` — 统计卡片组件(4 个指标卡片)
|
||||
- `ActionListWidget.tsx` — 待办列表组件
|
||||
- `FunnelWidget.tsx` — 漏斗图组件
|
||||
- `CardListWidget.tsx` — 卡片列表组件
|
||||
|
||||
### Task 5.4: 验证
|
||||
|
||||
- [ ] `cargo check` 通过
|
||||
- [ ] 前端 `pnpm build` 通过
|
||||
- [ ] manifest.rs 正确解析 widgets TOML
|
||||
- [ ] API 返回 widgets 定义和聚合数据
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: freelance + itops Layer 2 — 仪表盘 widgets
|
||||
|
||||
> **前置:** Phase 5 完成(平台支持 widgets)
|
||||
|
||||
### Task 6.1: freelance — 替换仪表盘页面为 widgets 版本
|
||||
|
||||
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
将现有的空仪表盘(第 949-952 行)替换为包含 4 个 widgets 的完整仪表盘:
|
||||
1. stat_cards — 财务概览(4 张卡片)
|
||||
2. action_list — 紧急待办(4 种查询)
|
||||
3. funnel — 商机漏斗
|
||||
4. card_list — 活跃项目
|
||||
|
||||
### Task 6.2: itops — 新增仪表盘页面到最前面
|
||||
|
||||
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
在现有页面列表最前面插入仪表盘页面(2 个 widgets):
|
||||
1. stat_cards — 运维概览(4 张卡片)
|
||||
2. action_list — 紧急待办(3 种查询)
|
||||
|
||||
### Task 6.3: 两个插件各自编译 WASM + 升级
|
||||
|
||||
### Task 6.4: 验证
|
||||
|
||||
- [ ] freelance 仪表盘 → 4 个 widget 正确渲染
|
||||
- [ ] itops 仪表盘 → 2 个 widget 正确渲染
|
||||
- [ ] 财务卡片数值正确(调用 aggregate API)
|
||||
- [ ] 紧急待办列表有数据时显示条目
|
||||
- [ ] 商机漏斗按阶段显示金额分布
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## 执行策略
|
||||
|
||||
**P1-P4 并行策略:** P1 和 P2 可以同时开始(不同文件),P3 和 P4 在 P1/P2 完成后立即跟进。每个 Phase 独立编译 WASM、独立验证、独立提交。
|
||||
|
||||
**P5-P6 顺序策略:** P5 是平台改动(Rust + 前端),P6 依赖 P5 的平台能力才能生效。
|
||||
|
||||
**预估工作量:**
|
||||
- P1: 30-40 分钟(plugin.toml 编辑 + 编译 + 验证)
|
||||
- P2: 20-30 分钟(规模小于 P1)
|
||||
- P3: 15-20 分钟(3 个模板插入)
|
||||
- P4: 10-15 分钟(1 个模板插入)
|
||||
- P5: 60-90 分钟(manifest 扩展 + API + 前端组件)
|
||||
- P6: 20-30 分钟(plugin.toml widgets 声明)
|
||||
Reference in New Issue
Block a user