# 插件开发 Skill 基于 CRM / 进销存插件的开发经验,提炼可复用的插件开发流程和模式。 ## 触发场景 - 用户说"开发一个新插件"、"新建行业模块"、"创建插件" - 用户提到需要在 ERP 平台上扩展新的业务模块 ## 插件开发流程 ### 第一步:需求分析 → 数据模型 1. 确定插件 ID(如 `erp-crm`、`erp-inventory`) 2. 列出实体及其字段,为每个字段标注: - `field_type`: String/Integer/Float/Boolean/Date/DateTime/Uuid/Decimal/Json - `required` / `unique` / `searchable` / `filterable` / `sortable` - `visible_when`: 条件显示表达式(如 `type == 'enterprise'`) - `ui_widget`: 表单控件(input/select/textarea/datepicker/entity_select/number) - `options`: select 类型的选项列表 - `ref_entity` / `ref_label_field` / `ref_search_fields`: 外键引用(entity_select 下拉) - `cascade_from` / `cascade_filter`: 级联过滤(如选了客户后过滤联系人) - `scope_role`: 标记数据权限的"所有者"字段(用于行级权限 self/department 模式) 3. 确定实体间关系(`relations`)和级联策略(`cascade`/`nullify`/`restrict`) 4. 确定是否需要行级数据权限(`data_scope = true`) ### 第二步:编写 plugin.toml manifest ```toml [metadata] id = "erp-xxx" name = "模块名称" version = "0.1.0" description = "描述" author = "ERP Team" min_platform_version = "0.1.0" # 权限:code 必须是 {entity_name}.{list|manage},与下面的 entities[].name 完全一致 [[permissions]] code = "entity.list" name = "查看 XX" description = "查看 XX 列表和详情" data_scope_levels = ["self", "department", "department_tree", "all"] [[permissions]] code = "entity.manage" name = "管理 XX" description = "创建、编辑、删除 XX" # 事件订阅(可选) [events] subscribe = ["order.*", "user.created"] # 实体定义 [[schema.entities]] name = "entity" # ← 这个 name 必须与上面 permissions 的 code 前缀一致 display_name = "实体名" data_scope = true # 启用行级数据权限(可选) [[schema.entities.fields]] name = "field_name" field_type = "String" required = true display_name = "字段名" searchable = true # pg_trgm 全文搜索索引 filterable = true # Generated Column + B-tree 索引,前端渲染 Select 筛选 sortable = true # Generated Column + B-tree 索引,表格列头排序 # 外键引用示例 [[schema.entities.fields]] name = "customer_id" field_type = "Uuid" ref_entity = "customer" ref_label_field = "name" ref_search_fields = ["name", "phone"] ui_widget = "entity_select" # 实体关系 [[schema.entities.relations]] entity = "contact" foreign_key = "customer_id" on_delete = "cascade" # cascade | nullify | restrict # 页面声明 [[ui.pages]] type = "crud" entity = "entity" label = "页面标题" icon = "icon-name" enable_search = true ``` > **铁律:** `permissions[].code` 的前缀(`.`之前的部分)必须与 `schema.entities[].name` **完全一致**。每个实体必须声明 `.list` 和 `.manage` 两个权限。不一致会导致页面返回 403。 ### 第三步:创建 Rust crate ```bash mkdir -p crates/erp-plugin-xxx/src ``` **Cargo.toml**: ```toml [package] name = "erp-plugin-xxx" version = "0.1.0" edition = "2024" [lib] crate-type = ["cdylib"] [dependencies] wit-bindgen = "0.55" serde = { workspace = true } serde_json = { workspace = true } ``` **src/lib.rs**: ```rust wit_bindgen::generate!({ path: "../erp-plugin-prototype/wit/plugin.wit", world: "plugin-world", }); use crate::exports::erp::plugin::plugin_api::Guest; struct XxxPlugin; impl Guest for XxxPlugin { fn init() -> Result<(), String> { Ok(()) } fn on_tenant_created(_tenant_id: String) -> Result<(), String> { Ok(()) } fn handle_event(_event_type: String, _payload: Vec) -> Result<(), String> { Ok(()) } } export!(XxxPlugin); ``` ### WASM 内部可用的 Host API 插件在 `init`、`on_tenant_created`、`handle_event` 中可以调用以下 Host API: | API | 说明 | 使用模式 | |-----|------|---------| | `db_insert(entity, data)` | 插入记录,自动注入 tenant_id/id/created_at | 延迟刷新 | | `db_query(entity, filter, pagination)` | 查询记录,支持 filter 和分页 | **实时查询**(自动先 flush pending writes) | | `db_update(entity, id, data, version)` | 更新记录,乐观锁 version 检查 | 延迟刷新 | | `db_delete(entity, id)` | 软删除 | 延迟刷新 | | `event_publish(event_type, payload)` | 发布领域事件 | 事务提交后投递 | | `config_get(key)` | 读取系统配置 | 预填充 | | `log_write(level, message)` | 写日志 | 立即 | | `current_user()` | 获取当前用户 id + tenant_id | 立即 | | `check_permission(permission)` | 检查当前用户权限 | 立即 | **filter 格式**(JSON): ```json {"status": "active", "type": "enterprise"} ``` **pagination 格式**(JSON): ```json {"limit": 50, "offset": 0} ``` **读后写一致性**:`db_query` 会先自动 flush 所有 pending writes,确保能读取到自己刚插入的数据。 **限制**:Fuel 默认 1000 万条 WASM 指令,执行超时 30 秒。避免重计算循环。 ### 第四步:注册到 workspace 根 `Cargo.toml` 的 `[workspace] members` 添加 `"crates/erp-plugin-xxx"`。 ### 第五步:编译和转换 ```bash cargo build -p erp-plugin-xxx --target wasm32-unknown-unknown --release wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_xxx.wasm -o target/erp_plugin_xxx.component.wasm ``` ### 第六步:上传和测试 PluginAdmin 页面上传 `.component.wasm` + `plugin.toml`:上传 → 安装 → 启用。 ### 升级流程 上传新版本时,系统自动对比 schema 差异: - **新增实体** → CREATE TABLE - **已有实体新增 filterable/sortable/searchable 字段** → ALTER TABLE ADD Generated Column + 索引 - **JSONB 字段新增** → 无需 DDL,更新 manifest 即可 - 热更新采用原子替换:先加载新版本到临时 slot,成功后替换旧版本,失败则旧版本继续运行 ## 可用页面类型 | 类型 | 说明 | 必填配置 | |------|------|----------| | `crud` | 增删改查表格 | `entity`, `label` | | `tree` | 树形展示 | `entity`, `label`, `id_field`, `parent_field`, `label_field` | | `detail` | 详情 Drawer | `entity`, `label`, `sections` | | `tabs` | 标签页容器 | `label`, `tabs`(子页面列表) | | `graph` | 关系图谱 | `entity`, `label`, `edges`(节点关系定义) | | `dashboard` | 统计概览 | `label`, `widgets`(stat_card/pie_chart/funnel_chart 等) | | `kanban` | 看板拖拽 | `entity`, `label`, `status_field` | ## detail section 类型 | 类型 | 说明 | 配置 | |------|------|------| | `fields` | 字段描述列表 | `label`, `fields`(字段名数组) | | `crud` | 嵌套 CRUD 表格 | `label`, `entity`, `filter_field` | ## 字段属性速查 | 属性 | 说明 | |------|------| | `searchable` | 可搜索,自动创建 pg_trgm GIN 索引 | | `filterable` | 可筛选,Generated Column + B-tree 索引,前端渲染 Select | | `sortable` | 可排序,Generated Column + B-tree 索引,表格列头排序 | | `visible_when` | 条件显示,格式 `field == 'value'` | | `unique` | 唯一约束,CREATE UNIQUE INDEX | | `ref_entity` | 外键引用,配合 `ui_widget = "entity_select"` | | `cascade_from` | 级联过滤,如选了客户后过滤联系人 | | `scope_role` | 数据权限所有者字段,行级权限 self/department 模式依赖此字段 | | `ui_widget` | 控件:select / textarea / entity_select / number / datepicker | | `options` | select 选项 `[{label, value}]` | | `validation.pattern` | 正则校验 | ## 行级数据权限 在实体上设置 `data_scope = true`,并在权限中声明 `data_scope_levels`: ```toml [[schema.entities]] name = "order" data_scope = true [[schema.entities.fields]] name = "owner_id" field_type = "Uuid" scope_role = true # 标记此字段为"所有者" [[permissions]] code = "order.list" data_scope_levels = ["self", "department", "department_tree", "all"] ``` 四种 scope 含义: - `self` — 只能看自己创建的(scope_role 字段匹配当前用户) - `department` — 本部门成员创建的 - `department_tree` — 本部门及下级部门 - `all` — 不限 ## 权限规则(铁律) **权限码必须与实体名完全一致。** 这是系统自动校验的基础,不一致会导致 403。 ### 命名约定 - manifest 中声明:`{entity_name}.{list|manage}` - 数据库存储:`{manifest_id}.{entity_name}.{list|manage}` - 运行时校验:`data_handler` 按 URL 中的 entity name 自动拼接为 `{manifest_id}.{entity_name}.{list|manage}` ### 正确示例 ```toml # 实体名是 customer_tag → 权限码必须用 customer_tag [[schema.entities]] name = "customer_tag" # ← 实体名 display_name = "客户标签" [[permissions]] code = "customer_tag.list" # ← 必须与实体名一致! name = "查看客户标签" [[permissions]] code = "customer_tag.manage" # ← 必须与实体名一致! name = "管理客户标签" ``` ### 规则总结 1. **每个实体必须声明 `.list` 和 `.manage` 两个权限**(缺一不可) 2. **权限码前缀必须与 `schema.entities[].name` 完全一致** 3. 安装时自动加 manifest_id 前缀(如 `erp-crm.customer_tag.list`) 4. REST API 动态检查,无精细权限时回退 `plugin.list` / `plugin.admin` ## REST API | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/v1/plugins/{id}/{entity}` | 列表(filter/search/sort) | | POST | `/api/v1/plugins/{id}/{entity}` | 创建(required 校验) | | GET | `/api/v1/plugins/{id}/{entity}/{rid}` | 详情 | | PUT | `/api/v1/plugins/{id}/{entity}/{rid}` | 更新(乐观锁) | | DELETE | `/api/v1/plugins/{id}/{entity}/{rid}` | 软删除 | | GET | `/api/v1/plugins/{id}/{entity}/count` | 统计 | | GET | `/api/v1/plugins/{id}/{entity}/aggregate` | 聚合(GROUP BY + COUNT) | | POST | `/api/v1/plugins/{id}/{entity}/aggregate-multi` | 多聚合(COUNT + SUM/AVG/MIN/MAX) | | GET | `/api/v1/plugins/{id}/{entity}/timeseries` | 时间序列聚合 | | POST | `/api/v1/plugins/{id}/{entity}/batch` | 批量操作(delete/update,上限 100 条) | ### aggregate-multi 请求体 ```json { "group_by": "status", "aggregations": [ {"func": "sum", "field": "amount"}, {"func": "avg", "field": "price"} ], "filter": {"status": "active"} } ``` ## 存储模型 所有业务数据存入 JSONB `data` 列,高频查询字段通过 **Generated Column** 提取为独立列(`_f_{field_name}`): - `filterable` / `sortable` / `unique` 字段 → `_f_{name}` Generated Column + B-tree 索引 - `searchable` String 字段 → `_f_{name}` Generated Column + pg_trgm GIN 索引 - 其余字段仅存在于 JSONB 中,无需 DDL ## 测试检查清单 - [ ] `cargo check --workspace` 通过 - [ ] `cargo test --workspace` 通过 - [ ] WASM 编译 + Component 转换成功 - [ ] 上传 → 安装 → 启用流程正常 - [ ] CRUD 完整可用 - [ ] 唯一字段重复插入返回冲突 - [ ] filter/search/sort 查询正常 - [ ] visible_when 条件字段动态显示 - [ ] 侧边栏菜单正确生成 - [ ] 行级数据权限正常(self/department/all) - [ ] entity_select 外键引用和级联过滤正常 - [ ] dashboard 聚合数据正确 ## 常见陷阱 1. **权限码必须与实体名一致(P0 级)**:`permissions[].code` 的前缀必须与 `schema.entities[].name` 完全匹配。不一致会导致 403 2. **每个实体必须同时声明 `.list` 和 `.manage`**:漏掉 `.list` 会导致列表/详情查询 403,漏掉 `.manage` 会导致增删改 403 3. 表名格式:`plugin_{sanitized_id}_{sanitized_entity}`,连字符变下划线 4. edition 必须是 "2024" 5. WIT 路径:`../erp-plugin-prototype/wit/plugin.wit`,不是 `erp-plugin` 6. JSONB 无外键约束,Uuid 字段不自动校验引用完整性(需在 WASM 中自行校验) 7. Fuel 限制 1000 万,执行超时 30 秒,避免重计算循环 8. manifest 中只写 `entity.action`,安装时自动加 manifest_id 前缀 9. `db_query` 使用 Generated Column 路径(`_f_` 前缀)过滤,确保字段标记了 `filterable` 10. 热更新时已有实体的普通字段新增不需要 DDL(JSONB 天然支持),但 `filterable`/`sortable` 新字段会自动 ALTER TABLE