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:
iven
2026-04-19 00:49:00 +08:00
parent 1dbda4c1e8
commit ef89ed38a1
12 changed files with 1425 additions and 24 deletions

View File

@@ -6,7 +6,7 @@ use erp_core::audit_service;
use erp_core::error::{AppError, AppResult};
use erp_core::events::EventBus;
use crate::data_dto::{BatchActionReq, PluginDataResp};
use crate::data_dto::{AggregateMultiRow, BatchActionReq, PluginDataResp};
use crate::dynamic_table::{sanitize_identifier, DynamicTableManager};
use crate::entity::plugin;
use crate::entity::plugin_entity;
@@ -700,6 +700,70 @@ impl PluginDataService {
Ok(result)
}
/// 多聚合查询 — 支持 COUNT + SUM/AVG/MIN/MAX
pub async fn aggregate_multi(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
group_by_field: &str,
aggregations: &[(String, String)],
filter: Option<serde_json::Value>,
scope: Option<DataScopeParams>,
) -> AppResult<Vec<AggregateMultiRow>> {
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let (mut sql, mut values) = DynamicTableManager::build_aggregate_multi_sql(
&info.table_name,
tenant_id,
group_by_field,
aggregations,
filter,
)
.map_err(|e| AppError::Validation(e))?;
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
if !scope_condition.0.is_empty() {
sql = merge_scope_condition(sql, &scope_condition);
values.extend(scope_condition.1);
}
// 使用 json_agg 包装整行,返回 JSON 数组
let json_sql = format!("SELECT json_agg(row_to_json(t)) as data FROM ({}) t", sql);
#[derive(Debug, FromQueryResult)]
struct JsonResult {
data: Option<serde_json::Value>,
}
let result = JsonResult::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
json_sql,
values,
))
.one(db)
.await?;
let json_rows: Vec<serde_json::Value> = result
.and_then(|r| r.data)
.and_then(|d| d.as_array().cloned())
.unwrap_or_default();
let rows = json_rows.into_iter().map(|v| AggregateMultiRow {
key: v.get("key").and_then(|k| k.as_str()).unwrap_or_default().to_string(),
count: v.get("count").and_then(|c| c.as_i64()).unwrap_or(0),
metrics: v.as_object()
.map(|m| m.iter()
.filter(|(k, _)| *k != "key" && *k != "count")
.map(|(k, v)| (k.clone(), v.as_f64().unwrap_or(0.0)))
.collect()
)
.unwrap_or_default(),
}).collect();
Ok(rows)
}
/// 聚合查询(预留 Redis 缓存接口)
pub async fn aggregate_cached(
plugin_id: Uuid,
@@ -866,6 +930,77 @@ pub async fn resolve_entity_info_cached(
Ok(info)
}
/// 跨插件实体解析 — 按 manifest_id + entity_name 查找目标插件的实体信息
pub async fn resolve_cross_plugin_entity(
target_manifest_id: &str,
entity_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AppResult<EntityInfo> {
let entity = plugin_entity::Entity::find()
.filter(plugin_entity::Column::ManifestId.eq(target_manifest_id))
.filter(plugin_entity::Column::EntityName.eq(entity_name))
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
.filter(plugin_entity::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"跨插件实体 {}/{} 不存在或未公开",
target_manifest_id, entity_name
))
})?;
let entity_def: crate::manifest::PluginEntity =
serde_json::from_value(entity.schema_json.clone())
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
let generated_fields: Vec<String> = entity_def
.fields
.iter()
.filter(|f| f.field_type.supports_generated_column())
.filter(|f| {
f.unique
|| f.sortable == Some(true)
|| f.filterable == Some(true)
|| (f.required && (f.sortable == Some(true) || f.filterable == Some(true)))
})
.map(|f| sanitize_identifier(&f.name))
.collect();
Ok(EntityInfo {
table_name: entity.table_name,
schema_json: entity.schema_json,
generated_fields,
})
}
/// 检查目标插件是否安装且活跃
pub async fn is_plugin_active(
target_manifest_id: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> bool {
// 通过 plugin_entities 的 manifest_id 找到 plugin_id再检查 plugins 表状态
let entity = plugin_entity::Entity::find()
.filter(plugin_entity::Column::ManifestId.eq(target_manifest_id))
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
.filter(plugin_entity::Column::DeletedAt.is_null())
.one(db)
.await;
let Some(entity) = entity.ok().flatten() else {
return false;
};
let plugin = plugin::Entity::find_by_id(entity.plugin_id)
.filter(plugin::Column::TenantId.eq(tenant_id))
.filter(plugin::Column::DeletedAt.is_null())
.one(db)
.await;
matches!(plugin.ok().flatten(), Some(p) if p.status == "running" || p.status == "installed")
}
/// 校验数据:检查 required 字段 + 正则校验
fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> {
let obj = data.as_object().ok_or_else(|| {
@@ -904,6 +1039,8 @@ fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<
}
/// 校验外键引用 — 检查 ref_entity 字段指向的记录是否存在
/// 支持同插件引用和跨插件引用ref_plugin 字段)
/// 核心原则:跨插件引用目标插件未安装时跳过校验(软警告)
async fn validate_ref_entities(
data: &serde_json::Value,
fields: &[PluginField],
@@ -935,19 +1072,47 @@ async fn validate_ref_entities(
})?;
// 自引用 + create跳过记录尚未存在
if ref_entity_name == current_entity && is_create {
if ref_entity_name == current_entity && field.ref_plugin.is_none() && is_create {
continue;
}
// 自引用 + update检查是否引用自身
if ref_entity_name == current_entity && !is_create {
if ref_entity_name == current_entity && field.ref_plugin.is_none() && !is_create {
if let Some(rid) = record_id {
if ref_id == rid { continue; }
}
}
// 查询被引用记录是否存在
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
let ref_table = DynamicTableManager::table_name(&manifest_id, ref_entity_name);
// 确定目标表名
let ref_table = if let Some(target_plugin) = &field.ref_plugin {
// 跨插件引用 — 检查目标插件是否活跃
if !is_plugin_active(target_plugin, tenant_id, db).await {
// 目标插件未安装/禁用 → 跳过校验(软警告,不阻塞)
tracing::debug!(
field = %field.name,
target_plugin = %target_plugin,
"跨插件引用目标插件未活跃,跳过校验"
);
continue;
}
// 目标插件活跃 → 解析目标表名
match resolve_cross_plugin_entity(target_plugin, ref_entity_name, tenant_id, db).await {
Ok(info) => info.table_name,
Err(e) => {
tracing::warn!(
field = %field.name,
target_plugin = %target_plugin,
entity = %ref_entity_name,
error = %e,
"跨插件实体解析失败,跳过校验"
);
continue;
}
}
} else {
// 同插件引用 — 使用原有逻辑
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
DynamicTableManager::table_name(&manifest_id, ref_entity_name)
};
let check_sql = format!(
"SELECT 1 as check_result FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",