diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs index 31e146e..e70596b 100644 --- a/crates/erp-plugin/src/data_dto.rs +++ b/crates/erp-plugin/src/data_dto.rs @@ -55,6 +55,18 @@ pub struct AggregateItem { pub count: i64, } +/// 多聚合查询响应项 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct AggregateMultiRow { + /// 分组键 + pub key: String, + /// 计数 + pub count: i64, + /// 聚合指标: {"sum_amount": 5000.0, "avg_price": 25.5} + #[serde(default)] + pub metrics: std::collections::HashMap, +} + /// 聚合查询参数 #[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] pub struct AggregateQueryParams { @@ -64,6 +76,26 @@ pub struct AggregateQueryParams { pub filter: Option, } +/// 多聚合查询请求体 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct AggregateMultiReq { + /// 分组字段名 + pub group_by: String, + /// 聚合定义列表: [{"func": "sum", "field": "amount"}] + pub aggregations: Vec, + /// JSON 格式过滤 + pub filter: Option, +} + +/// 单个聚合定义 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct AggregateDefDto { + /// 聚合函数: count, sum, avg, min, max + pub func: String, + /// 字段名 + pub field: String, +} + /// 统计查询参数 #[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] pub struct CountQueryParams { @@ -105,3 +137,29 @@ pub struct TimeseriesItem { /// 计数 pub count: i64, } + +// ─── 跨插件引用 DTO ────────────────────────────────────────────────── + +/// 批量标签解析请求 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ResolveLabelsReq { + /// 字段名 → UUID 列表 + pub fields: std::collections::HashMap>, +} + +/// 批量标签解析响应 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ResolveLabelsResp { + /// 字段名 → { uuid: label } 映射 + pub labels: serde_json::Value, + /// 字段名 → 目标插件元信息 + pub meta: serde_json::Value, +} + +/// 公开实体信息(实体注册表查询响应) +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PublicEntityResp { + pub manifest_id: String, + pub entity_name: String, + pub display_name: String, +} diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs index b974f1e..336ecb2 100644 --- a/crates/erp-plugin/src/data_service.rs +++ b/crates/erp-plugin/src/data_service.rs @@ -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, + scope: Option, + ) -> AppResult> { + 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, + } + + 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 = 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 { + 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 = 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", diff --git a/crates/erp-plugin/src/dynamic_table.rs b/crates/erp-plugin/src/dynamic_table.rs index ffa341e..6c03498 100644 --- a/crates/erp-plugin/src/dynamic_table.rs +++ b/crates/erp-plugin/src/dynamic_table.rs @@ -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, + pub new_sortable: Vec, + pub new_searchable: Vec, +} + /// 动态表管理器 — 处理插件动态创建/删除的数据库表 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 = + 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 { #[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, + ) -> Result<(String, Vec), 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 = 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); diff --git a/crates/erp-plugin/src/engine.rs b/crates/erp-plugin/src/engine.rs index ac2dc70..38c93da 100644 --- a/crates/erp-plugin/src/engine.rs +++ b/crates/erp-plugin/src/engine.rs @@ -1,8 +1,9 @@ +use std::collections::HashMap; use std::panic::AssertUnwindSafe; use std::sync::Arc; use dashmap::DashMap; -use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, TransactionTrait}; +use sea_orm::{ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, Statement, TransactionTrait}; use serde_json::json; use tokio::sync::RwLock; use uuid::Uuid; @@ -315,6 +316,18 @@ impl PluginEngine { Ok(()) } + /// 将插件从一个 key 重命名为另一个 key(用于热更新的原子替换) + pub async fn rename_plugin(&self, old_id: &str, new_id: &str) -> PluginResult<()> { + let (_, loaded) = self.plugins.remove(old_id) + .ok_or_else(|| PluginError::NotFound(old_id.to_string()))?; + let mut loaded = Arc::try_unwrap(loaded) + .map_err(|_| PluginError::ExecutionError("插件仍被引用,无法重命名".to_string()))?; + loaded.id = new_id.to_string(); + self.plugins.insert(new_id.to_string(), Arc::new(loaded)); + tracing::info!(old_id, new_id, "Plugin renamed"); + Ok(()) + } + /// 健康检查 pub async fn health_check(&self, plugin_id: &str) -> PluginResult { let loaded = self.get_loaded(plugin_id)?; @@ -456,13 +469,20 @@ impl PluginEngine { { let loaded = self.get_loaded(plugin_id)?; + // 构建跨插件实体映射(从 manifest 的 ref_plugin 字段提取) + let cross_plugin_entities = Self::build_cross_plugin_map(&loaded.manifest, &self.db, exec_ctx.tenant_id).await; + // 创建新的 Store + HostState,使用真实的租户/用户上下文 - let state = HostState::new( + // 传入 db 和 event_bus 启用混合执行模式(插件可自主查询数据) + let mut state = HostState::new_with_db( plugin_id.to_string(), exec_ctx.tenant_id, exec_ctx.user_id, exec_ctx.permissions.clone(), + self.db.clone(), + self.event_bus.clone(), ); + state.cross_plugin_entities = cross_plugin_entities; let mut store = Store::new(&self.engine, state); store .set_fuel(self.config.default_fuel) @@ -521,6 +541,42 @@ impl PluginEngine { result } + /// 从 manifest 的 ref_plugin 字段构建跨插件实体映射 + /// 返回: { "erp-crm.customer" → "plugin_erp_crm__customer", ... } + async fn build_cross_plugin_map( + manifest: &crate::manifest::PluginManifest, + db: &DatabaseConnection, + tenant_id: Uuid, + ) -> HashMap { + let mut map = HashMap::new(); + let Some(schema) = &manifest.schema else { return map }; + + for entity in &schema.entities { + for field in &entity.fields { + if let (Some(target_plugin), Some(ref_entity)) = (&field.ref_plugin, &field.ref_entity) { + let key = format!("{}.{}", target_plugin, ref_entity); + // 从 plugin_entities 表查找目标表名 + let table_name = crate::entity::plugin_entity::Entity::find() + .filter(crate::entity::plugin_entity::Column::ManifestId.eq(target_plugin.as_str())) + .filter(crate::entity::plugin_entity::Column::EntityName.eq(ref_entity.as_str())) + .filter(crate::entity::plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(crate::entity::plugin_entity::Column::DeletedAt.is_null()) + .one(db) + .await + .ok() + .flatten() + .map(|e| e.table_name); + + if let Some(tn) = table_name { + map.insert(key, tn); + } + } + } + } + + map + } + /// 刷新 HostState 中的 pending_ops 到数据库。 /// /// 使用事务包裹所有数据库操作确保原子性。 diff --git a/crates/erp-plugin/src/entity/plugin_entity.rs b/crates/erp-plugin/src/entity/plugin_entity.rs index 08ecf26..af24934 100644 --- a/crates/erp-plugin/src/entity/plugin_entity.rs +++ b/crates/erp-plugin/src/entity/plugin_entity.rs @@ -11,6 +11,8 @@ pub struct Model { pub entity_name: String, pub table_name: String, pub schema_json: serde_json::Value, + pub manifest_id: String, + pub is_public: bool, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs index ce16f7c..2181102 100644 --- a/crates/erp-plugin/src/handler/data_handler.rs +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -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>), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// POST /api/v1/plugins/{plugin_id}/{entity}/aggregate-multi — 多聚合查询 +pub async fn aggregate_multi_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Json(body): Json, +) -> Result>>, AppError> +where + PluginState: FromRef, + 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( + Path((plugin_id, entity)): Path<(Uuid, String)>, + State(state): State, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, AppError> +where + PluginState: FromRef, +{ + 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::::new(); + let mut meta = serde_json::Map::::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 = 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 = 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 = 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 = (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 = vec![ctx.tenant_id.into()]; + for u in uuid_strs { + values.push(u.into()); + } + + #[derive(FromQueryResult)] + struct LabelRow { id: String, label: Option } + + 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 = 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( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + PluginState: FromRef, +{ + 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 = 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))) +} diff --git a/crates/erp-plugin/src/host.rs b/crates/erp-plugin/src/host.rs index d163e8c..0ea455d 100644 --- a/crates/erp-plugin/src/host.rs +++ b/crates/erp-plugin/src/host.rs @@ -1,9 +1,12 @@ use std::collections::HashMap; +use sea_orm::DatabaseConnection; use uuid::Uuid; use wasmtime::StoreLimits; use crate::erp::plugin::host_api; +use crate::dynamic_table::DynamicTableManager; +use crate::engine::PluginEngine; /// 待刷新的写操作 #[derive(Debug)] @@ -31,10 +34,9 @@ pub enum PendingOp { /// Host 端状态 — 绑定到每个 WASM Store 实例 /// -/// 采用延迟执行模式: -/// - 读操作 (db_query, config_get, current_user) → 调用前预填充 -/// - 写操作 (db_insert, db_update, db_delete, event_publish) → 入队 pending_ops -/// - WASM 调用结束后由 engine 刷新 pending_ops 执行真实 DB 操作 +/// 支持两种执行模式: +/// - **预填充模式**(db = None):读操作从预填充缓存取,向后兼容 +/// - **混合执行模式**(db = Some):读操作走实时 SQL + 写操作保持延迟批量 pub struct HostState { pub(crate) limits: StoreLimits, #[allow(dead_code)] @@ -43,7 +45,7 @@ pub struct HostState { pub(crate) user_id: Uuid, pub(crate) permissions: Vec, pub(crate) plugin_id: String, - // 预填充的读取缓存 + // 预填充的读取缓存(向后兼容) pub(crate) query_results: HashMap>, pub(crate) config_cache: HashMap>, pub(crate) current_user_json: Vec, @@ -51,6 +53,11 @@ pub struct HostState { pub(crate) pending_ops: Vec, // 日志 pub(crate) logs: Vec<(String, String)>, + // 混合执行模式:数据库连接和事件总线 + pub(crate) db: Option, + pub(crate) event_bus: Option, + // 跨插件实体映射:"erp-crm.customer" → "plugin_erp_crm__customer" + pub(crate) cross_plugin_entities: HashMap, } impl HostState { @@ -75,8 +82,26 @@ impl HostState { current_user_json: serde_json::to_vec(¤t_user).unwrap_or_default(), pending_ops: Vec::new(), logs: Vec::new(), + db: None, + event_bus: None, + cross_plugin_entities: HashMap::new(), } } + + /// 创建带数据库连接的 HostState(混合执行模式) + pub fn new_with_db( + plugin_id: String, + tenant_id: Uuid, + user_id: Uuid, + permissions: Vec, + db: DatabaseConnection, + event_bus: erp_core::events::EventBus, + ) -> Self { + let mut state = Self::new(plugin_id, tenant_id, user_id, permissions); + state.db = Some(db); + state.event_bus = Some(event_bus); + state + } } // 实现 bindgen 生成的 Host trait — 插件调用 Host API 的入口 @@ -99,13 +124,110 @@ impl host_api::Host for HostState { fn db_query( &mut self, entity: String, - _filter: Vec, - _pagination: Vec, + filter: Vec, + pagination: Vec, ) -> Result, String> { - self.query_results - .get(&entity) - .cloned() - .ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity)) + // 预填充模式(向后兼容) + if self.db.is_none() { + return self.query_results + .get(&entity) + .cloned() + .ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity)); + } + + let db = self.db.clone().unwrap(); + let event_bus = self.event_bus.clone() + .ok_or("事件总线不可用")?; + + // 先 flush pending writes(确保读后写一致性) + let ops = std::mem::take(&mut self.pending_ops); + if !ops.is_empty() { + let rt = tokio::runtime::Handle::current(); + rt.block_on(PluginEngine::flush_ops( + &db, + &self.plugin_id, + ops, + self.tenant_id, + self.user_id, + &event_bus, + )) + .map_err(|e| format!("flush pending ops 失败: {}", e))?; + } + + // 解析 filter 和 pagination + let filter_val: Option = if filter.is_empty() { + None + } else { + serde_json::from_slice(&filter).ok() + }; + + let pagination_val: Option = if pagination.is_empty() { + None + } else { + serde_json::from_slice(&pagination).ok() + }; + + // 构建查询 — 支持点分记号跨插件查询(如 "erp-crm.customer") + let table_name = if entity.contains('.') { + self.cross_plugin_entities + .get(&entity) + .cloned() + .ok_or_else(|| format!("跨插件实体 '{}' 未注册", entity))? + } else { + DynamicTableManager::table_name(&self.plugin_id, &entity) + }; + + let limit = pagination_val + .as_ref() + .and_then(|p| p.get("limit")) + .and_then(|v| v.as_u64()) + .unwrap_or(50); + let offset = pagination_val + .as_ref() + .and_then(|p| p.get("offset")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let (sql, values) = DynamicTableManager::build_filtered_query_sql( + &table_name, + self.tenant_id, + limit, + offset, + filter_val, + None, + None, + None, + ) + .map_err(|e| format!("查询构建失败: {}", e))?; + + // 执行查询 + let rt = tokio::runtime::Handle::current(); + let rows = rt.block_on(async { + use sea_orm::{FromQueryResult, Statement}; + #[derive(Debug, FromQueryResult)] + struct QueryRow { + data: serde_json::Value, + } + + let results = QueryRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .all(&db) + .await + .map_err(|e| format!("查询执行失败: {}", e))?; + + let items: Vec = results + .into_iter() + .map(|r| r.data) + .collect(); + + Ok::, String>(items) + }) + .map_err(|e: String| e)?; + + serde_json::to_vec(&rows).map_err(|e| e.to_string()) } fn db_update( diff --git a/crates/erp-plugin/src/manifest.rs b/crates/erp-plugin/src/manifest.rs index 67c157a..e529dfb 100644 --- a/crates/erp-plugin/src/manifest.rs +++ b/crates/erp-plugin/src/manifest.rs @@ -47,6 +47,8 @@ pub struct PluginEntity { pub relations: Vec, #[serde(default)] pub data_scope: Option, // 是否启用行级数据权限 + #[serde(default)] + pub is_public: Option, // 是否可被其他插件引用 } /// 字段校验规则 @@ -87,6 +89,8 @@ pub struct PluginField { pub no_cycle: Option, // 禁止循环引用 #[serde(default)] pub scope_role: Option, // 标记为数据权限的"所有者"字段 + pub ref_plugin: Option, // 跨插件引用的目标插件 manifest ID(如 "erp-crm") + pub ref_fallback_label: Option, // 目标插件未安装时的降级显示文本 } /// 字段类型 @@ -158,6 +162,8 @@ impl PluginField { validation: None, no_cycle: None, scope_role: None, + ref_plugin: None, + ref_fallback_label: None, } } } @@ -186,6 +192,12 @@ pub struct PluginRelation { pub entity: String, pub foreign_key: String, pub on_delete: OnDeleteStrategy, + #[serde(default)] + pub name: Option, // 关联名称(UI 显示用) + #[serde(default, alias = "type")] + pub relation_type: Option, // "one_to_many" | "many_to_one" | "many_to_many" + #[serde(default)] + pub display_field: Option, // 关联记录的显示字段 } /// 事件订阅配置 @@ -916,6 +928,98 @@ cascade_filter = "customer_id" assert_eq!(contact_field.cascade_filter.as_deref(), Some("customer_id")); } + #[test] + fn parse_field_with_cross_plugin_ref() { + let toml = r#" +[metadata] +id = "erp-inventory" +name = "进销存" +version = "0.2.0" +dependencies = ["erp-crm"] + +[schema] +[[schema.entities]] +name = "sales_order" +display_name = "销售订单" + +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +display_name = "客户" +ui_widget = "entity_select" +ref_plugin = "erp-crm" +ref_entity = "customer" +ref_label_field = "name" +ref_search_fields = ["name", "code"] +ref_fallback_label = "CRM 客户" +"#; + let manifest = parse_manifest(toml).unwrap(); + let field = &manifest.schema.unwrap().entities[0].fields[0]; + assert_eq!(field.ref_plugin.as_deref(), Some("erp-crm")); + assert_eq!(field.ref_entity.as_deref(), Some("customer")); + assert_eq!(field.ref_label_field.as_deref(), Some("name")); + assert_eq!(field.ref_fallback_label.as_deref(), Some("CRM 客户")); + assert_eq!(manifest.metadata.dependencies, vec!["erp-crm"]); + } + + #[test] + fn parse_entity_with_is_public() { + let toml = r#" +[metadata] +id = "erp-crm" +name = "CRM" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "customer" +display_name = "客户" +is_public = true + +[[schema.entities]] +name = "internal_config" +display_name = "内部配置" +"#; + let manifest = parse_manifest(toml).unwrap(); + let entities = &manifest.schema.unwrap().entities; + assert_eq!(entities[0].is_public, Some(true)); + assert_eq!(entities[1].is_public, None); + } + + #[test] + fn parse_relation_with_name_and_type() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "customer" +display_name = "客户" + +[[schema.entities.fields]] +name = "code" +field_type = "string" +display_name = "编码" + +[[schema.entities.relations]] +entity = "contact" +foreign_key = "customer_id" +on_delete = "cascade" +name = "contacts" +type = "one_to_many" +display_field = "name" +"#; + let manifest = parse_manifest(toml).unwrap(); + let relation = &manifest.schema.unwrap().entities[0].relations[0]; + assert_eq!(relation.entity, "contact"); + assert_eq!(relation.name.as_deref(), Some("contacts")); + assert_eq!(relation.relation_type.as_deref(), Some("one_to_many")); + assert_eq!(relation.display_field.as_deref(), Some("name")); + } + #[test] fn parse_kanban_page() { let toml = r#" diff --git a/crates/erp-plugin/src/module.rs b/crates/erp-plugin/src/module.rs index db95779..126920f 100644 --- a/crates/erp-plugin/src/module.rs +++ b/crates/erp-plugin/src/module.rs @@ -91,6 +91,10 @@ impl PluginModule { "/plugins/{plugin_id}/{entity}/aggregate", get(crate::handler::data_handler::aggregate_plugin_data::), ) + .route( + "/plugins/{plugin_id}/{entity}/aggregate-multi", + post(crate::handler::data_handler::aggregate_multi_plugin_data::), + ) // 批量操作路由 .route( "/plugins/{plugin_id}/{entity}/batch", @@ -100,8 +104,20 @@ impl PluginModule { .route( "/plugins/{plugin_id}/{entity}/timeseries", get(crate::handler::data_handler::get_plugin_timeseries::), + ) + // 跨插件引用:批量标签解析 + .route( + "/plugins/{plugin_id}/{entity}/resolve-labels", + post(crate::handler::data_handler::resolve_ref_labels::), ); - admin_routes.merge(data_routes) + // 实体注册表路由 + let registry_routes = Router::new() + .route( + "/plugin-registry/entities", + get(crate::handler::data_handler::list_public_entities::), + ); + + admin_routes.merge(data_routes).merge(registry_routes) } } diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index b0d740a..bacfeee 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -37,6 +37,8 @@ mod m20260417_000034_seed_plugin_permissions; mod m20260418_000035_pg_trgm_and_entity_columns; mod m20260418_000036_add_data_scope_to_role_permissions; mod m20260419_000037_create_user_departments; +mod m20260419_000038_fix_crm_permission_codes; +mod m20260419_000039_entity_registry_columns; pub struct Migrator; @@ -81,6 +83,8 @@ impl MigratorTrait for Migrator { Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration), Box::new(m20260418_000036_add_data_scope_to_role_permissions::Migration), Box::new(m20260419_000037_create_user_departments::Migration), + Box::new(m20260419_000038_fix_crm_permission_codes::Migration), + Box::new(m20260419_000039_entity_registry_columns::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260419_000039_entity_registry_columns.rs b/crates/erp-server/migration/src/m20260419_000039_entity_registry_columns.rs new file mode 100644 index 0000000..62cf2fb --- /dev/null +++ b/crates/erp-server/migration/src/m20260419_000039_entity_registry_columns.rs @@ -0,0 +1,51 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // plugin_entities 新增 manifest_id 列 — 避免跨插件查询时 JOIN plugins 表 + manager + .get_connection() + .execute_unprepared( + r#" +ALTER TABLE plugin_entities +ADD COLUMN IF NOT EXISTS manifest_id TEXT NOT NULL DEFAULT ''; + +ALTER TABLE plugin_entities +ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false; + +-- 回填 manifest_id(从 plugins.manifest_json 提取 metadata.id) +UPDATE plugin_entities pe +SET manifest_id = COALESCE(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; +"#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared( + r#" +DROP INDEX IF EXISTS idx_plugin_entities_cross_ref; +ALTER TABLE plugin_entities DROP COLUMN IF EXISTS is_public; +ALTER TABLE plugin_entities DROP COLUMN IF EXISTS manifest_id; +"#, + ) + .await?; + + Ok(()) + } +} diff --git a/plans/eager-sleeping-yao.md b/plans/eager-sleeping-yao.md new file mode 100644 index 0000000..db86b18 --- /dev/null +++ b/plans/eager-sleeping-yao.md @@ -0,0 +1,430 @@ +# 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` 字段 | 比设计文档的 `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)新增: + +```rust +pub ref_plugin: Option, // 目标插件 manifest ID(如 "erp-crm") +pub ref_fallback_label: Option, // 目标插件未安装时的降级显示文本 +``` + +两个新字段加 `#[serde(default)]`,向后兼容。 + +### 1.2 manifest.rs — 扩展 PluginRelation + +CRM 的 plugin.toml 已在使用 `name`/`type`/`display_field`,但当前 struct 只解析 `entity`/`foreign_key`/`on_delete`,其余被 serde 静默丢弃。补齐: + +```rust +pub struct PluginRelation { + pub entity: String, + pub foreign_key: String, + pub on_delete: OnDeleteStrategy, + pub name: Option, // serde(default) + pub relation_type: Option, // serde(default), "one_to_many" 等 + pub display_field: Option, // serde(default) +} +``` + +### 1.3 manifest.rs — 扩展 PluginEntity + +新增 `is_public` 标记实体是否可被其他插件引用: + +```rust +pub is_public: Option, // serde(default), false by default +``` + +### 1.4 数据库迁移 — plugin_entities 新增列 + +新迁移文件:`crates/erp-server/migration/src/m{timestamp}_entity_registry_columns.rs` + +```sql +-- 新增 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` + +新增字段映射: +```rust +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` 记录时,设置: +```rust +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_entities` 和 `db_query` 能解析其他插件的实体表。 + +### 2.1 data_service.rs — 跨插件实体解析 + +文件:`crates/erp-plugin/src/data_service.rs` + +新增函数: + +```rust +/// 按 manifest_id + entity_name 跨插件解析实体信息 +pub async fn resolve_cross_plugin_entity( + target_manifest_id: &str, + entity_name: &str, + tenant_id: Uuid, + db: &DatabaseConnection, +) -> AppResult +``` + +查询 `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`: +```rust +pub(crate) cross_plugin_entities: HashMap, +// key: "erp-crm.customer" → value: "plugin_erp_crm__customer" +``` + +修改 `db_query`(~line 168): +```rust +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` 字段解析跨插件实体映射: + +```rust +// 从 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 +``` + +请求: +```json +{ "fields": { "customer_id": ["uuid1", "uuid2"] } } +``` + +响应: +```json +{ + "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 +``` + +响应: +```json +{ + "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` + +```typescript +// PluginFieldSchema 新增 +ref_plugin?: string; +ref_fallback_label?: string; + +// PluginEntitySchema 新增 +is_public?: boolean; +``` + +文件:`apps/web/src/api/pluginData.ts` 新增: + +```typescript +resolveRefLabels(pluginId, entity, fields): Promise +getPluginEntityRegistry(params?): Promise +scanDanglingRefs(pluginId): Promise +``` + +### 4.2 EntitySelect 跨插件支持 + +文件:`apps/web/src/components/EntitySelect.tsx` + +新增 props:`refPlugin?: string`, `fallbackLabel?: string` + +核心改动: +- `refPlugin` 存在时 → 调用 `listPluginData(refPlugin, entity, ...)` 而非 `listPluginData(pluginId, entity, ...)` +- 目标插件不可达(404) → 显示灰色禁用 Input + 警告图标 + fallbackLabel +- 正常情况 → 保持现有 Select 行为 + +### 4.3 PluginCRUDPage 表格列标签解析 + +文件:`apps/web/src/pages/PluginCRUDPage.tsx` + +**新增 hook:useResolveRefLabels** + +数据加载后,收集所有 ref 字段的 UUID 值,调用 `resolveRefLabels` 批量获取标签。 + +**修改列渲染**(~line 263): + +```typescript +// ref 字段渲染逻辑 +if (f.ref_entity) { + const label = resolvedLabels[f.name]?.[uuid]; + const installed = labelMeta[f.name]?.plugin_installed !== false; + if (!installed) return {f.ref_fallback_label || '外部引用'}; + if (label === null) return 无效引用; + return {label}; +} +``` + +**修改 entity_select 表单渲染**(~line 341): + +传 `refPlugin` 和 `fallbackLabel` 给 EntitySelect。 + +### 4.4 Detail Drawer 引用标签 + +在详情 Descriptions 中,对 ref 字段同样展示解析后的标签而非裸 UUID。 + +--- + +## Phase 5: 悬空引用对账 + +### 5.1 新增 reconciliation.rs + +文件:`crates/erp-plugin/src/reconciliation.rs` + +```rust +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 +``` + +逻辑:遍历插件所有实体的 `ref_plugin` 字段,批量校验每个引用 UUID 是否存在于目标表。 + +### 5.2 数据库表 + +新迁移创建 `plugin_ref_scan_results` 表: + +```sql +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` 通过 +- [ ] 浏览器端到端操作验证