实现跨插件实体引用的基础后端能力:
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 公开实体注册表查询
13 KiB
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)新增:
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 静默丢弃。补齐:
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 标记实体是否可被其他插件引用:
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
-- 新增 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
新增字段映射:
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 记录时,设置:
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
新增函数:
/// 按 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。
改为:
- 若
field.ref_plugin存在 → 用ref_plugin作为 target_manifest_id - 检查目标插件是否安装且活跃(查
plugins表 status in["running","installed"]) - 目标插件活跃 → 解析目标表名 → 执行 UUID 存在性校验(与现有逻辑相同)
- 目标插件未安装/禁用 → 跳过校验(软警告,不阻塞)
- 若
field.ref_plugin不存在 → 走原有同插件逻辑(完全兼容)
2.3 host.rs — HostState 跨插件实体映射
文件:crates/erp-plugin/src/host.rs
新增字段到 HostState:
pub(crate) cross_plugin_entities: HashMap<String, String>,
// key: "erp-crm.customer" → value: "plugin_erp_crm__customer"
修改 db_query(~line 168):
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 字段解析跨插件实体映射:
// 从 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
请求:
{ "fields": { "customer_id": ["uuid1", "uuid2"] } }
响应:
{
"success": true,
"data": {
"customer_id": { "uuid1": "张三", "uuid2": "李四" },
"_meta": {
"customer_id": {
"target_plugin": "erp-crm",
"target_entity": "customer",
"label_field": "name",
"plugin_installed": true
}
}
}
}
逻辑:
- 从 entity schema 读取每个 field 的
ref_plugin/ref_entity/ref_label_field - 对每个 field,解析目标表名(同 Phase 2 逻辑)
SELECT id, data->>'label_field' as label FROM target_table WHERE id = ANY($1) AND tenant_id = $2- 目标插件未安装 → 返回
{ uuid: null }+plugin_installed: false
3.2 实体注册表查询
文件:crates/erp-plugin/src/handler/data_handler.rs
GET /api/v1/plugin-registry/entities?is_public=true
响应:
{
"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
// PluginFieldSchema 新增
ref_plugin?: string;
ref_fallback_label?: string;
// PluginEntitySchema 新增
is_public?: boolean;
文件:apps/web/src/api/pluginData.ts 新增:
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):
// 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
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 表:
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通过 - 浏览器端到端操作验证