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:
@@ -4,7 +4,7 @@ use uuid::Uuid;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
|
||||
use crate::error::{PluginError, PluginResult};
|
||||
use crate::manifest::{PluginEntity, PluginFieldType};
|
||||
use crate::manifest::{PluginEntity, PluginField, PluginFieldType};
|
||||
|
||||
/// 消毒标识符:只保留 ASCII 字母、数字、下划线,防止 SQL 注入
|
||||
pub(crate) fn sanitize_identifier(input: &str) -> String {
|
||||
@@ -14,6 +14,13 @@ pub(crate) fn sanitize_identifier(input: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Schema 演进字段差异
|
||||
pub struct FieldDiff {
|
||||
pub new_filterable: Vec<PluginField>,
|
||||
pub new_sortable: Vec<PluginField>,
|
||||
pub new_searchable: Vec<PluginField>,
|
||||
}
|
||||
|
||||
/// 动态表管理器 — 处理插件动态创建/删除的数据库表
|
||||
pub struct DynamicTableManager;
|
||||
|
||||
@@ -158,6 +165,102 @@ impl DynamicTableManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Schema 演进:对比新旧实体字段,返回需要新增 Generated Column 的差异
|
||||
pub fn diff_entity_fields(old: &PluginEntity, new: &PluginEntity) -> FieldDiff {
|
||||
let old_names: std::collections::HashSet<String> =
|
||||
old.fields.iter().map(|f| f.name.clone()).collect();
|
||||
|
||||
let mut new_filterable = Vec::new();
|
||||
let mut new_sortable = Vec::new();
|
||||
let mut new_searchable = Vec::new();
|
||||
|
||||
for field in &new.fields {
|
||||
if old_names.contains(&field.name) {
|
||||
continue;
|
||||
}
|
||||
// 新增字段 + 需要 Generated Column 的条件
|
||||
let needs_gen = field.unique
|
||||
|| field.sortable == Some(true)
|
||||
|| field.filterable == Some(true);
|
||||
if needs_gen {
|
||||
new_filterable.push(field.clone());
|
||||
if field.sortable == Some(true) {
|
||||
new_sortable.push(field.clone());
|
||||
}
|
||||
}
|
||||
if field.searchable == Some(true) && matches!(field.field_type, PluginFieldType::String) {
|
||||
new_searchable.push(field.clone());
|
||||
}
|
||||
}
|
||||
|
||||
FieldDiff { new_filterable, new_sortable, new_searchable }
|
||||
}
|
||||
|
||||
/// Schema 演进:为已有实体新增 Generated Column 和索引
|
||||
pub async fn alter_add_generated_columns(
|
||||
db: &DatabaseConnection,
|
||||
plugin_id: &str,
|
||||
entity: &PluginEntity,
|
||||
diff: &FieldDiff,
|
||||
) -> PluginResult<()> {
|
||||
let table_name = Self::table_name(plugin_id, &entity.name);
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for field in &diff.new_filterable {
|
||||
if !field.field_type.supports_generated_column() {
|
||||
continue;
|
||||
}
|
||||
let col_name = format!("_f_{}", sanitize_identifier(&field.name));
|
||||
let sql_type = field.field_type.generated_sql_type();
|
||||
let expr = field.field_type.generated_expr(&sanitize_identifier(&field.name));
|
||||
let _safe_field = sanitize_identifier(&field.name);
|
||||
|
||||
statements.push(format!(
|
||||
"ALTER TABLE \"{}\" ADD COLUMN IF NOT EXISTS \"{}\" {} GENERATED ALWAYS AS ({}) STORED",
|
||||
table_name, col_name, sql_type, expr
|
||||
));
|
||||
|
||||
let col_idx = format!("{}_{}", sanitize_identifier(&table_name), col_name);
|
||||
if field.unique {
|
||||
statements.push(format!(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS \"idx_{}_uniq\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL",
|
||||
col_idx, table_name, col_name
|
||||
));
|
||||
} else {
|
||||
statements.push(format!(
|
||||
"CREATE INDEX IF NOT EXISTS \"idx_{}\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL",
|
||||
col_idx, table_name, col_name
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for field in &diff.new_searchable {
|
||||
let sf = sanitize_identifier(&field.name);
|
||||
let col_name = format!("_f_{}", sf);
|
||||
let col_idx = format!("{}_{}trgm", sanitize_identifier(&table_name), col_name);
|
||||
statements.push(format!(
|
||||
"CREATE INDEX IF NOT EXISTS \"idx_{}\" ON \"{}\" USING gin (\"{}\" gin_trgm_ops) WHERE deleted_at IS NULL AND \"{}\" IS NOT NULL",
|
||||
col_idx, table_name, col_name, col_name
|
||||
));
|
||||
}
|
||||
|
||||
for sql in &statements {
|
||||
tracing::info!(sql = %sql, "Executing ALTER TABLE");
|
||||
db.execute_unprepared(sql).await.map_err(|e| {
|
||||
tracing::error!(sql = %sql, error = %e, "ALTER TABLE failed");
|
||||
PluginError::DatabaseError(e.to_string())
|
||||
})?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
table = %table_name,
|
||||
added_columns = diff.new_filterable.len(),
|
||||
added_search_indexes = diff.new_searchable.len(),
|
||||
"Schema evolution: Generated Columns added"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查表是否存在
|
||||
pub async fn table_exists(db: &DatabaseConnection, table_name: &str) -> PluginResult<bool> {
|
||||
#[derive(FromQueryResult)]
|
||||
@@ -461,6 +564,82 @@ impl DynamicTableManager {
|
||||
Ok((sql, values))
|
||||
}
|
||||
|
||||
/// 构建多聚合函数 SQL(支持 COUNT/SUM/AVG/MIN/MAX)
|
||||
pub fn build_aggregate_multi_sql(
|
||||
table_name: &str,
|
||||
tenant_id: Uuid,
|
||||
group_by_field: &str,
|
||||
aggregations: &[(String, String)], // (func, field) e.g. ("sum", "amount")
|
||||
filter: Option<serde_json::Value>,
|
||||
) -> Result<(String, Vec<Value>), String> {
|
||||
let clean_group = sanitize_identifier(group_by_field);
|
||||
if clean_group.is_empty() {
|
||||
return Err(format!("无效的分组字段名: {}", group_by_field));
|
||||
}
|
||||
|
||||
let mut conditions = vec![
|
||||
format!("\"tenant_id\" = ${}", 1),
|
||||
"\"deleted_at\" IS NULL".to_string(),
|
||||
];
|
||||
let mut param_idx = 2;
|
||||
let mut values: Vec<Value> = vec![tenant_id.into()];
|
||||
|
||||
if let Some(f) = filter {
|
||||
if let Some(obj) = f.as_object() {
|
||||
for (key, val) in obj {
|
||||
let clean_key = sanitize_identifier(key);
|
||||
if clean_key.is_empty() {
|
||||
return Err(format!("无效的过滤字段名: {}", key));
|
||||
}
|
||||
conditions.push(format!("\"data\"->>'{}' = ${}", clean_key, param_idx));
|
||||
values.push(Value::String(Some(Box::new(
|
||||
val.as_str().unwrap_or("").to_string(),
|
||||
))));
|
||||
param_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut select_parts = vec![
|
||||
format!("\"_f_{}\" as key", clean_group),
|
||||
"COUNT(*) as count".to_string(),
|
||||
];
|
||||
|
||||
for (func, field) in aggregations {
|
||||
let clean_field = sanitize_identifier(field);
|
||||
let func_lower = func.to_lowercase();
|
||||
match func_lower.as_str() {
|
||||
"sum" => select_parts.push(format!(
|
||||
"COALESCE(SUM(\"_f_{}\"), 0) as sum_{}", clean_field, clean_field
|
||||
)),
|
||||
"avg" => select_parts.push(format!(
|
||||
"COALESCE(AVG(\"_f_{}\"), 0) as avg_{}", clean_field, clean_field
|
||||
)),
|
||||
"min" => select_parts.push(format!(
|
||||
"MIN(\"_f_{}\") as min_{}", clean_field, clean_field
|
||||
)),
|
||||
"max" => select_parts.push(format!(
|
||||
"MAX(\"_f_{}\") as max_{}", clean_field, clean_field
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {} \
|
||||
FROM \"{}\" \
|
||||
WHERE {} \
|
||||
GROUP BY \"_f_{}\" \
|
||||
ORDER BY count DESC",
|
||||
select_parts.join(", "),
|
||||
table_name,
|
||||
conditions.join(" AND "),
|
||||
clean_group,
|
||||
);
|
||||
|
||||
Ok((sql, values))
|
||||
}
|
||||
|
||||
/// 构建带过滤条件的查询 SQL
|
||||
pub fn build_filtered_query_sql(
|
||||
table_name: &str,
|
||||
@@ -1132,6 +1311,7 @@ mod tests {
|
||||
indexes: vec![],
|
||||
relations: vec![],
|
||||
data_scope: None,
|
||||
is_public: None,
|
||||
};
|
||||
|
||||
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
||||
@@ -1174,6 +1354,7 @@ mod tests {
|
||||
indexes: vec![],
|
||||
relations: vec![],
|
||||
data_scope: None,
|
||||
is_public: None,
|
||||
};
|
||||
|
||||
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
||||
|
||||
Reference in New Issue
Block a user