feat(plugin): P1 跨插件数据引用系统 — 后端 Phase 1-3
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

实现跨插件实体引用的基础后端能力:

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

@@ -8,9 +8,10 @@ use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::data_dto::{
AggregateItem, AggregateQueryParams, BatchActionReq, CountQueryParams, CreatePluginDataReq,
PatchPluginDataReq, PluginDataListParams, PluginDataResp, TimeseriesItem, TimeseriesParams,
UpdatePluginDataReq,
AggregateItem, AggregateMultiReq, AggregateMultiRow, AggregateQueryParams, BatchActionReq,
CountQueryParams, CreatePluginDataReq, PatchPluginDataReq, PluginDataListParams,
PluginDataResp, PublicEntityResp, ResolveLabelsReq, ResolveLabelsResp,
TimeseriesItem, TimeseriesParams, UpdatePluginDataReq,
};
use crate::data_service::{DataScopeParams, PluginDataService, resolve_manifest_id};
use crate::state::PluginState;
@@ -566,3 +567,214 @@ async fn check_entity_data_scope(
Ok(schema.data_scope.unwrap_or(false))
}
#[utoipa::path(
post,
path = "/api/v1/plugins/{plugin_id}/{entity}/aggregate-multi",
request_body = AggregateMultiReq,
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<AggregateMultiRow>>),
),
security(("bearer_auth" = [])),
tag = "插件数据"
)]
/// POST /api/v1/plugins/{plugin_id}/{entity}/aggregate-multi — 多聚合查询
pub async fn aggregate_multi_plugin_data<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity)): Path<(Uuid, String)>,
Json(body): Json<AggregateMultiReq>,
) -> Result<Json<ApiResponse<Vec<AggregateMultiRow>>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
require_permission(&ctx, &fine_perm)?;
let scope = resolve_data_scope(
&ctx, &manifest_id, &entity, &fine_perm, &state.db,
).await?;
let aggregations: Vec<(String, String)> = body.aggregations
.iter()
.map(|a| (a.func.clone(), a.field.clone()))
.collect();
let rows = PluginDataService::aggregate_multi(
plugin_id,
&entity,
ctx.tenant_id,
&state.db,
&body.group_by,
&aggregations,
body.filter,
scope,
)
.await?;
Ok(Json(ApiResponse::ok(rows)))
}
// ─── 跨插件引用:批量标签解析 ────────────────────────────────────────
/// 批量解析引用字段的显示标签
///
/// POST /api/v1/plugins/{plugin_id}/{entity}/resolve-labels
pub async fn resolve_ref_labels<S>(
Path((plugin_id, entity)): Path<(Uuid, String)>,
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<ResolveLabelsReq>,
) -> Result<Json<ApiResponse<ResolveLabelsResp>>, AppError>
where
PluginState: FromRef<S>,
{
use sea_orm::{FromQueryResult, Statement};
use crate::data_service::{resolve_cross_plugin_entity, is_plugin_active};
use crate::manifest::PluginEntity;
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
require_permission(&ctx, &fine_perm)?;
// 获取当前实体的 schema
let entity_info = crate::data_service::resolve_entity_info_cached(
plugin_id, &entity, ctx.tenant_id, &state.db, &state.entity_cache,
).await?;
let entity_def: PluginEntity =
serde_json::from_value(entity_info.schema_json).map_err(|e|
AppError::Internal(format!("解析 entity schema 失败: {}", e))
)?;
let mut labels = serde_json::Map::<String, serde_json::Value>::new();
let mut meta = serde_json::Map::<String, serde_json::Value>::new();
for (field_name, uuids) in &body.fields {
// 查找字段定义
let field_def = entity_def.fields.iter().find(|f| &f.name == field_name);
let Some(field_def) = field_def else { continue };
let Some(ref_entity_name) = &field_def.ref_entity else { continue };
let target_plugin = field_def.ref_plugin.as_deref().unwrap_or(&manifest_id);
let label_field = field_def.ref_label_field.as_deref().unwrap_or("name");
let installed = is_plugin_active(target_plugin, ctx.tenant_id, &state.db).await;
// meta 信息
meta.insert(field_name.clone(), serde_json::json!({
"target_plugin": target_plugin,
"target_entity": ref_entity_name,
"label_field": label_field,
"plugin_installed": installed,
}));
if !installed {
// 目标插件未安装 → 所有 UUID 返回 null
let nulls: serde_json::Map<String, serde_json::Value> = uuids.iter()
.map(|u| (u.clone(), serde_json::Value::Null))
.collect();
labels.insert(field_name.clone(), serde_json::Value::Object(nulls));
continue;
}
// 解析目标表名
let target_table = if field_def.ref_plugin.is_some() {
match resolve_cross_plugin_entity(target_plugin, ref_entity_name, ctx.tenant_id, &state.db).await {
Ok(info) => info.table_name,
Err(_) => {
let nulls: serde_json::Map<String, serde_json::Value> = uuids.iter()
.map(|u| (u.clone(), serde_json::Value::Null))
.collect();
labels.insert(field_name.clone(), serde_json::Value::Object(nulls));
continue;
}
}
} else {
crate::dynamic_table::DynamicTableManager::table_name(target_plugin, ref_entity_name)
};
// 批量查询标签
let uuid_strs: Vec<String> = uuids.iter().filter_map(|u| Uuid::parse_str(u).ok()).map(|u| u.to_string()).collect();
if uuid_strs.is_empty() {
labels.insert(field_name.clone(), serde_json::json!({}));
continue;
}
// 构建 IN 子句参数
let placeholders: Vec<String> = (2..uuid_strs.len() + 2).map(|i| format!("${}", i)).collect();
let sql = format!(
"SELECT id::text, data->>'{}' as label FROM \"{}\" WHERE id IN ({}) AND tenant_id = $1 AND deleted_at IS NULL",
label_field, target_table, placeholders.join(", ")
);
let mut values: Vec<sea_orm::Value> = vec![ctx.tenant_id.into()];
for u in uuid_strs {
values.push(u.into());
}
#[derive(FromQueryResult)]
struct LabelRow { id: String, label: Option<String> }
let rows = LabelRow::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
)).all(&state.db).await?;
let mut field_labels: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
// 初始化所有请求的 UUID 为 null
for u in uuids {
field_labels.insert(u.clone(), serde_json::Value::Null);
}
// 用查询结果填充
for row in rows {
field_labels.insert(row.id, serde_json::Value::String(row.label.unwrap_or_default()));
}
labels.insert(field_name.clone(), serde_json::Value::Object(field_labels));
}
Ok(Json(ApiResponse::ok(ResolveLabelsResp {
labels: serde_json::Value::Object(labels),
meta: serde_json::Value::Object(meta),
})))
}
// ─── 跨插件引用:实体注册表查询 ────────────────────────────────────────
/// 查询所有可跨插件引用的公开实体
///
/// GET /api/v1/plugin-registry/entities
pub async fn list_public_entities<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<PublicEntityResp>>>, AppError>
where
PluginState: FromRef<S>,
{
use crate::entity::plugin_entity;
use sea_orm::{EntityTrait, QueryFilter, ColumnTrait};
let entities = plugin_entity::Entity::find()
.filter(plugin_entity::Column::TenantId.eq(ctx.tenant_id))
.filter(plugin_entity::Column::IsPublic.eq(true))
.filter(plugin_entity::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
let result: Vec<PublicEntityResp> = entities.iter().map(|e| {
let display_name = e.schema_json.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or(&e.entity_name)
.to_string();
PublicEntityResp {
manifest_id: e.manifest_id.clone(),
entity_name: e.entity_name.clone(),
display_name,
}
}).collect();
Ok(Json(ApiResponse::ok(result)))
}