Files
erp/.claude/skills/plugin-development/SKILL.md
iven 841766b168
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(用户管理): 修复用户列表页面加载失败问题
修复用户列表页面加载失败导致测试超时的问题,确保页面元素正确渲染
2026-04-19 08:46:28 +08:00

347 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 插件开发 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<u8>) -> 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. 热更新时已有实体的普通字段新增不需要 DDLJSONB 天然支持),但 `filterable`/`sortable` 新字段会自动 ALTER TABLE