Files
erp/plans/eager-sleeping-yao.md
iven ef89ed38a1
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
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 公开实体注册表查询
2026-04-19 00:49:00 +08:00

431 lines
13 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.

# 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`
**新增 hookuseResolveRefLabels**
数据加载后,收集所有 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` 通过
- [ ] 浏览器端到端操作验证