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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user