# 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` 字段 | 比设计文档的 `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, // 目标插件 manifest ID(如 "erp-crm") pub ref_fallback_label: Option, // 目标插件未安装时的降级显示文本 ``` 两个新字段加 `#[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, // serde(default) pub relation_type: Option, // serde(default), "one_to_many" 等 pub display_field: Option, // serde(default) } ``` ### 1.3 manifest.rs — 扩展 PluginEntity 新增 `is_public` 标记实体是否可被其他插件引用: ```rust pub is_public: Option, // 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 ``` 查询 `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, // 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 getPluginEntityRegistry(params?): Promise scanDanglingRefs(pluginId): Promise ``` ### 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 {f.ref_fallback_label || '外部引用'}; if (label === null) return 无效引用; return {label}; } ``` **修改 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 ``` 逻辑:遍历插件所有实体的 `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` 通过 - [ ] 浏览器端到端操作验证