feat(plugin): P1 跨插件数据引用系统 — 后端 Phase 1-3
实现跨插件实体引用的基础后端能力:
Phase 1 — Manifest 扩展 + Entity Registry 数据层:
- PluginField 新增 ref_plugin/ref_fallback_label 支持跨插件引用声明
- PluginRelation 新增 name/relation_type/display_field(CRM 已在用的字段)
- PluginEntity 新增 is_public 标记可被其他插件引用的实体
- 数据库迁移:plugin_entities 新增 manifest_id + is_public 列 + 索引
- SeaORM Entity 和 install 流程同步更新
Phase 2 — 后端跨插件引用解析 + 校验:
- data_service: 新增 resolve_cross_plugin_entity/is_plugin_active 函数
- validate_ref_entities: 支持 ref_plugin 字段,目标插件未安装时跳过校验(软警告)
- host.rs: HostState 新增 cross_plugin_entities 映射,db_query 支持点分记号
- engine.rs: execute_wasm 自动构建跨插件实体映射
Phase 3 — API 端点:
- POST /plugins/{id}/{entity}/resolve-labels 批量标签解析
- GET /plugin-registry/entities 公开实体注册表查询
This commit is contained in:
430
plans/eager-sleeping-yao.md
Normal file
430
plans/eager-sleeping-yao.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# P1 跨插件数据引用系统 — 实施计划
|
||||
|
||||
## Context
|
||||
|
||||
插件平台 P0 增强(混合执行模型/扩展聚合/原子回滚/Schema 演进)已全部完成。当前有两个行业插件(CRM + 进销存)运行在 WASM 插件系统上,但**跨插件数据引用完全不支持** — 进销存的 `customer_id` 只能存裸 UUID,无校验、无显示、无关联。
|
||||
|
||||
本计划实现 P1 跨插件数据引用系统,使插件能声明式引用其他插件的实体,并以财务插件作为验证载体。
|
||||
|
||||
**核心原则:** 外部引用永远是**软警告**,永不硬阻塞用户操作。
|
||||
|
||||
## 设计决策
|
||||
|
||||
| 决策点 | 方案 | 理由 |
|
||||
|--------|------|------|
|
||||
| Entity Registry | 复用 `plugin_entities` 表 + 新增 `manifest_id` 列 | 表已有 entity_name/table_name/schema_json,加列即可,无需新表 |
|
||||
| 跨插件引用标识 | 新增 `ref_plugin: Option<String>` 字段 | 比设计文档的 `ref_scope="external"` 更明确,直接指定目标插件 ID |
|
||||
| WIT 接口变更 | **不修改** WIT | 避免 recompile 所有插件,Host 层用点分记号 `"erp-crm.customer"` 解析 |
|
||||
| 表格列标签解析 | 新增批量 resolve-labels 端点 | O(1) 网络请求,`WHERE id = ANY($1)` 索引查找 |
|
||||
| 悬空引用对账 | 插件 re-enable 时异步触发 + 手动触发端点 | 不阻塞主流程,后台扫描 |
|
||||
|
||||
## 实施阶段总览
|
||||
|
||||
| Phase | 内容 | 依赖 | 预估 |
|
||||
|-------|------|------|------|
|
||||
| 1 | Manifest 扩展 + Entity Registry 数据层 | 无 | 1天 |
|
||||
| 2 | 后端跨插件引用解析 + 校验 | Phase 1 | 1天 |
|
||||
| 3 | API 端点(resolve-labels / registry / scan) | Phase 2 | 1天 |
|
||||
| 4 | 前端改造(EntitySelect + 列标签 + 降级) | Phase 3 | 1.5天 |
|
||||
| 5 | 悬空引用对账 | Phase 2 | 1天 |
|
||||
| 6 | 验证(进销存插件改造 + 端到端测试) | Phase 1-5 | 0.5天 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Manifest 扩展 + Entity Registry 数据层
|
||||
|
||||
> 纯数据结构和迁移,零运行时影响。现有插件完全兼容。
|
||||
|
||||
### 1.1 manifest.rs — 扩展 PluginField
|
||||
|
||||
文件:`crates/erp-plugin/src/manifest.rs`
|
||||
|
||||
在 `PluginField` struct(~line 82)新增:
|
||||
|
||||
```rust
|
||||
pub ref_plugin: Option<String>, // 目标插件 manifest ID(如 "erp-crm")
|
||||
pub ref_fallback_label: Option<String>, // 目标插件未安装时的降级显示文本
|
||||
```
|
||||
|
||||
两个新字段加 `#[serde(default)]`,向后兼容。
|
||||
|
||||
### 1.2 manifest.rs — 扩展 PluginRelation
|
||||
|
||||
CRM 的 plugin.toml 已在使用 `name`/`type`/`display_field`,但当前 struct 只解析 `entity`/`foreign_key`/`on_delete`,其余被 serde 静默丢弃。补齐:
|
||||
|
||||
```rust
|
||||
pub struct PluginRelation {
|
||||
pub entity: String,
|
||||
pub foreign_key: String,
|
||||
pub on_delete: OnDeleteStrategy,
|
||||
pub name: Option<String>, // serde(default)
|
||||
pub relation_type: Option<String>, // serde(default), "one_to_many" 等
|
||||
pub display_field: Option<String>, // serde(default)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 manifest.rs — 扩展 PluginEntity
|
||||
|
||||
新增 `is_public` 标记实体是否可被其他插件引用:
|
||||
|
||||
```rust
|
||||
pub is_public: Option<bool>, // serde(default), false by default
|
||||
```
|
||||
|
||||
### 1.4 数据库迁移 — plugin_entities 新增列
|
||||
|
||||
新迁移文件:`crates/erp-server/migration/src/m{timestamp}_entity_registry_columns.rs`
|
||||
|
||||
```sql
|
||||
-- 新增 manifest_id 列,避免每次 JOIN plugins 表
|
||||
ALTER TABLE plugin_entities
|
||||
ADD COLUMN IF NOT EXISTS manifest_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 新增 is_public 列
|
||||
ALTER TABLE plugin_entities
|
||||
ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- 回填 manifest_id(从 plugins.manifest_json 提取)
|
||||
UPDATE plugin_entities pe
|
||||
SET manifest_id = p.manifest_json->'metadata'->>'id'
|
||||
FROM plugins p
|
||||
WHERE pe.plugin_id = p.id AND pe.deleted_at IS NULL;
|
||||
|
||||
-- 跨插件查找索引
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_entities_cross_ref
|
||||
ON plugin_entities (manifest_id, entity_name, tenant_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
### 1.5 SeaORM Entity 更新
|
||||
|
||||
文件:`crates/erp-plugin/src/entity/plugin_entity.rs`
|
||||
|
||||
新增字段映射:
|
||||
```rust
|
||||
pub manifest_id: String, // Column("manifest_id")
|
||||
pub is_public: bool, // Column("is_public")
|
||||
```
|
||||
|
||||
### 1.6 service.rs — install 时填充新列
|
||||
|
||||
文件:`crates/erp-plugin/src/service.rs` (~line 112)
|
||||
|
||||
在 `install` 方法创建 `plugin_entity` 记录时,设置:
|
||||
```rust
|
||||
manifest_id: Set(manifest.metadata.id.clone()),
|
||||
is_public: Set(entity_def.is_public.unwrap_or(false)),
|
||||
```
|
||||
|
||||
### 1.7 单元测试
|
||||
|
||||
- 解析含 `ref_plugin` + `ref_fallback_label` 的字段
|
||||
- 解析含 `name`/`type`/`display_field` 的 relation
|
||||
- 解析含 `is_public` 的 entity
|
||||
- 旧格式 TOML(无新字段)仍正常解析
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 后端跨插件引用解析 + 校验
|
||||
|
||||
> 让 `validate_ref_entities` 和 `db_query` 能解析其他插件的实体表。
|
||||
|
||||
### 2.1 data_service.rs — 跨插件实体解析
|
||||
|
||||
文件:`crates/erp-plugin/src/data_service.rs`
|
||||
|
||||
新增函数:
|
||||
|
||||
```rust
|
||||
/// 按 manifest_id + entity_name 跨插件解析实体信息
|
||||
pub async fn resolve_cross_plugin_entity(
|
||||
target_manifest_id: &str,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> AppResult<EntityInfo>
|
||||
```
|
||||
|
||||
查询 `plugin_entities` 表(`manifest_id = target AND entity_name = name AND tenant_id AND deleted_at IS NULL`),构建 `EntityInfo`。
|
||||
|
||||
### 2.2 data_service.rs — 修改 validate_ref_entities
|
||||
|
||||
文件:`crates/erp-plugin/src/data_service.rs` (~line 971)
|
||||
|
||||
当前逻辑:`resolve_manifest_id(plugin_id)` → `table_name(manifest_id, ref_entity)` — 始终用本插件的 manifest_id。
|
||||
|
||||
改为:
|
||||
1. 若 `field.ref_plugin` 存在 → 用 `ref_plugin` 作为 target_manifest_id
|
||||
2. 检查目标插件是否安装且活跃(查 `plugins` 表 status in `["running","installed"]`)
|
||||
3. **目标插件活跃** → 解析目标表名 → 执行 UUID 存在性校验(与现有逻辑相同)
|
||||
4. **目标插件未安装/禁用** → **跳过校验**(软警告,不阻塞)
|
||||
5. 若 `field.ref_plugin` 不存在 → 走原有同插件逻辑(完全兼容)
|
||||
|
||||
### 2.3 host.rs — HostState 跨插件实体映射
|
||||
|
||||
文件:`crates/erp-plugin/src/host.rs`
|
||||
|
||||
新增字段到 `HostState`:
|
||||
```rust
|
||||
pub(crate) cross_plugin_entities: HashMap<String, String>,
|
||||
// key: "erp-crm.customer" → value: "plugin_erp_crm__customer"
|
||||
```
|
||||
|
||||
修改 `db_query`(~line 168):
|
||||
```rust
|
||||
let table_name = if entity.contains('.') {
|
||||
// 点分记号 "erp-crm.customer" → 跨插件查询
|
||||
self.cross_plugin_entities.get(&entity)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("跨插件实体 '{}' 未注册", entity))?
|
||||
} else {
|
||||
DynamicTableManager::table_name(&self.plugin_id, &entity)
|
||||
};
|
||||
```
|
||||
|
||||
### 2.4 engine.rs — 构建跨插件映射
|
||||
|
||||
文件:`crates/erp-plugin/src/engine.rs` (~line 473)
|
||||
|
||||
`execute_wasm` 创建 `HostState` 后,从 manifest 的所有 `ref_plugin` 字段解析跨插件实体映射:
|
||||
|
||||
```rust
|
||||
// 从 manifest 提取所有 ref_plugin + ref_entity 组合
|
||||
// 查 plugin_entities 表获取实际 table_name
|
||||
// 填入 HostState.cross_plugin_entities
|
||||
```
|
||||
|
||||
### 2.5 集成测试
|
||||
|
||||
- 同插件 ref_entity → 行为不变(回归)
|
||||
- 跨插件 ref_plugin + 目标插件活跃 → 校验通过/拒绝
|
||||
- 跨插件 ref_plugin + 目标插件未安装 → 跳过校验,不报错
|
||||
- host.rs db_query 点分记号 → 正确路由到目标插件表
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: API 端点
|
||||
|
||||
> 新增 3 个端点支撑前端跨插件功能。
|
||||
|
||||
### 3.1 批量标签解析(核心)
|
||||
|
||||
文件:`crates/erp-plugin/src/handler/data_handler.rs`
|
||||
|
||||
```
|
||||
POST /api/v1/plugins/{plugin_id}/{entity}/resolve-labels
|
||||
```
|
||||
|
||||
请求:
|
||||
```json
|
||||
{ "fields": { "customer_id": ["uuid1", "uuid2"] } }
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"customer_id": { "uuid1": "张三", "uuid2": "李四" },
|
||||
"_meta": {
|
||||
"customer_id": {
|
||||
"target_plugin": "erp-crm",
|
||||
"target_entity": "customer",
|
||||
"label_field": "name",
|
||||
"plugin_installed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
逻辑:
|
||||
1. 从 entity schema 读取每个 field 的 `ref_plugin` / `ref_entity` / `ref_label_field`
|
||||
2. 对每个 field,解析目标表名(同 Phase 2 逻辑)
|
||||
3. `SELECT id, data->>'label_field' as label FROM target_table WHERE id = ANY($1) AND tenant_id = $2`
|
||||
4. 目标插件未安装 → 返回 `{ uuid: null }` + `plugin_installed: false`
|
||||
|
||||
### 3.2 实体注册表查询
|
||||
|
||||
文件:`crates/erp-plugin/src/handler/data_handler.rs`
|
||||
|
||||
```
|
||||
GET /api/v1/plugin-registry/entities?is_public=true
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "manifest_id": "erp-crm", "entity_name": "customer", "display_name": "客户", "label_fields": ["name"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
从 `plugin_entities` 查询 `is_public = true AND deleted_at IS NULL`,关联 plugin 状态。
|
||||
|
||||
### 3.3 悬空引用扫描
|
||||
|
||||
文件:`crates/erp-plugin/src/handler/data_handler.rs`
|
||||
|
||||
```
|
||||
POST /api/v1/plugins/{plugin_id}/scan-dangling-refs
|
||||
```
|
||||
|
||||
异步触发扫描,返回扫描结果。详见 Phase 5。
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 前端改造
|
||||
|
||||
### 4.1 扩展 TypeScript 类型
|
||||
|
||||
文件:`apps/web/src/api/plugins.ts`
|
||||
|
||||
```typescript
|
||||
// PluginFieldSchema 新增
|
||||
ref_plugin?: string;
|
||||
ref_fallback_label?: string;
|
||||
|
||||
// PluginEntitySchema 新增
|
||||
is_public?: boolean;
|
||||
```
|
||||
|
||||
文件:`apps/web/src/api/pluginData.ts` 新增:
|
||||
|
||||
```typescript
|
||||
resolveRefLabels(pluginId, entity, fields): Promise<ResolveLabelsResult>
|
||||
getPluginEntityRegistry(params?): Promise<RegistryEntity[]>
|
||||
scanDanglingRefs(pluginId): Promise<ScanResult>
|
||||
```
|
||||
|
||||
### 4.2 EntitySelect 跨插件支持
|
||||
|
||||
文件:`apps/web/src/components/EntitySelect.tsx`
|
||||
|
||||
新增 props:`refPlugin?: string`, `fallbackLabel?: string`
|
||||
|
||||
核心改动:
|
||||
- `refPlugin` 存在时 → 调用 `listPluginData(refPlugin, entity, ...)` 而非 `listPluginData(pluginId, entity, ...)`
|
||||
- 目标插件不可达(404) → 显示灰色禁用 Input + 警告图标 + fallbackLabel
|
||||
- 正常情况 → 保持现有 Select 行为
|
||||
|
||||
### 4.3 PluginCRUDPage 表格列标签解析
|
||||
|
||||
文件:`apps/web/src/pages/PluginCRUDPage.tsx`
|
||||
|
||||
**新增 hook:useResolveRefLabels**
|
||||
|
||||
数据加载后,收集所有 ref 字段的 UUID 值,调用 `resolveRefLabels` 批量获取标签。
|
||||
|
||||
**修改列渲染**(~line 263):
|
||||
|
||||
```typescript
|
||||
// ref 字段渲染逻辑
|
||||
if (f.ref_entity) {
|
||||
const label = resolvedLabels[f.name]?.[uuid];
|
||||
const installed = labelMeta[f.name]?.plugin_installed !== false;
|
||||
if (!installed) return <Tag color="default">{f.ref_fallback_label || '外部引用'}</Tag>;
|
||||
if (label === null) return <Tag color="warning">无效引用</Tag>;
|
||||
return <Tag color="blue">{label}</Tag>;
|
||||
}
|
||||
```
|
||||
|
||||
**修改 entity_select 表单渲染**(~line 341):
|
||||
|
||||
传 `refPlugin` 和 `fallbackLabel` 给 EntitySelect。
|
||||
|
||||
### 4.4 Detail Drawer 引用标签
|
||||
|
||||
在详情 Descriptions 中,对 ref 字段同样展示解析后的标签而非裸 UUID。
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 悬空引用对账
|
||||
|
||||
### 5.1 新增 reconciliation.rs
|
||||
|
||||
文件:`crates/erp-plugin/src/reconciliation.rs`
|
||||
|
||||
```rust
|
||||
pub struct DanglingRef {
|
||||
pub entity: String,
|
||||
pub field: String,
|
||||
pub record_id: Uuid,
|
||||
pub ref_value: String,
|
||||
pub reason: String, // "target_not_found" | "target_plugin_disabled"
|
||||
}
|
||||
|
||||
pub async fn scan_dangling_refs(
|
||||
manifest_id: &str, tenant_id: Uuid, db: &DatabaseConnection
|
||||
) -> Vec<DanglingRef>
|
||||
```
|
||||
|
||||
逻辑:遍历插件所有实体的 `ref_plugin` 字段,批量校验每个引用 UUID 是否存在于目标表。
|
||||
|
||||
### 5.2 数据库表
|
||||
|
||||
新迁移创建 `plugin_ref_scan_results` 表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS plugin_ref_scan_results (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
plugin_id UUID NOT NULL REFERENCES plugins(id),
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
total_scanned INTEGER NOT NULL DEFAULT 0,
|
||||
dangling_count INTEGER NOT NULL DEFAULT 0,
|
||||
result_json JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
```
|
||||
|
||||
### 5.3 触发时机
|
||||
|
||||
- `service.rs::enable` 中:插件重新启用时,异步扫描依赖此插件的其他插件
|
||||
- 手动触发:管理员在 UI 点击 "扫描悬空引用"
|
||||
|
||||
### 5.4 前端
|
||||
|
||||
在 PluginAdmin 的插件详情 Drawer 中新增 "扫描引用" 按钮 + 扫描结果列表。
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: 验证
|
||||
|
||||
### 6.1 改造进销存插件
|
||||
|
||||
文件:`crates/erp-plugin-inventory/plugin.toml`
|
||||
|
||||
- `sales_order.customer_id` 增加 `ref_plugin = "erp-crm"`, `ref_entity = "customer"`, `ref_label_field = "name"`, `ref_fallback_label = "CRM 客户"`
|
||||
- `metadata.dependencies` 添加 `"erp-crm"`
|
||||
|
||||
### 6.2 改造 CRM 插件
|
||||
|
||||
文件:`crates/erp-plugin-crm/plugin.toml`
|
||||
|
||||
- `customer` 实体增加 `is_public = true`
|
||||
|
||||
### 6.3 端到端验证矩阵
|
||||
|
||||
| 场景 | 预期 |
|
||||
|------|------|
|
||||
| CRM 已安装 → 进销存创建订单选择客户 | EntitySelect 下拉显示 CRM 客户列表 |
|
||||
| CRM 未安装 → 进销存创建订单 | customer_id 字段降级为灰色文本输入 |
|
||||
| CRM 已安装 → 订单列表显示客户名 | 表格列显示蓝色 Tag "张三" |
|
||||
| CRM 卸载 → 重新安装 → 扫描悬空引用 | 对账报告显示悬空记录 |
|
||||
| 财务插件独立安装(无 CRM) | 所有功能正常,客户字段降级 |
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] `cargo check` 全 workspace 通过
|
||||
- [ ] `cargo test --workspace` 全部通过
|
||||
- [ ] 数据库迁移正/反向执行
|
||||
- [ ] 现有插件(CRM/进销存)功能不受影响
|
||||
- [ ] 新增端点通过 API 测试
|
||||
- [ ] 前端 `pnpm build` 通过
|
||||
- [ ] 浏览器端到端操作验证
|
||||
Reference in New Issue
Block a user