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

13 KiB
Raw Blame History

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_entitiesdb_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。

改为:

  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

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
      }
    }
  }
}

逻辑:

  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

响应:

{
  "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

新增 propsrefPlugin?: 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

// 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

refPluginfallbackLabel 给 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 通过
  • 浏览器端到端操作验证