From 0ad77693f47e8a62512718d7e72efd9d4acc38dd Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 16 Apr 2026 12:31:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E9=9B=86=E6=88=90=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E6=9F=A5=E8=AF=A2/=E6=8E=92=E5=BA=8F/=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=88=B0=20REST=20API=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=A0=A1=E9=AA=8C=E5=92=8C=20searchable=20?= =?UTF-8?q?=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - data_dto: PluginDataListParams 新增 filter/sort_by/sort_order - data_service: list 方法支持 filter/search/sort 参数,自动提取 searchable 字段 - data_service: create/update 添加 required 字段校验 - data_service: 新增 resolve_entity_fields 和 validate_data 辅助函数 - data_handler: 权限检查从硬编码改为动态计算 plugin_id.entity.action - dynamic_table: searchable 字段自动创建 B-tree 索引 --- crates/erp-plugin/src/data_dto.rs | 5 + crates/erp-plugin/src/data_service.rs | 100 +++++++++++++++--- crates/erp-plugin/src/dynamic_table.rs | 18 ++++ crates/erp-plugin/src/handler/data_handler.rs | 46 +++++++- 4 files changed, 151 insertions(+), 18 deletions(-) diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs index f4934c9..4b76114 100644 --- a/crates/erp-plugin/src/data_dto.rs +++ b/crates/erp-plugin/src/data_dto.rs @@ -30,4 +30,9 @@ pub struct PluginDataListParams { pub page: Option, pub page_size: Option, pub search: Option, + /// JSON 格式过滤: {"field":"value"} + pub filter: Option, + pub sort_by: Option, + /// "asc" or "desc" + pub sort_order: Option, } diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs index 8f5960e..6568287 100644 --- a/crates/erp-plugin/src/data_service.rs +++ b/crates/erp-plugin/src/data_service.rs @@ -1,13 +1,14 @@ use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, Statement}; use uuid::Uuid; -use erp_core::error::AppResult; +use erp_core::error::{AppError, AppResult}; use erp_core::events::EventBus; use crate::data_dto::PluginDataResp; use crate::dynamic_table::DynamicTableManager; use crate::entity::plugin_entity; use crate::error::PluginError; +use crate::manifest::PluginField; pub struct PluginDataService; @@ -22,6 +23,10 @@ impl PluginDataService { db: &sea_orm::DatabaseConnection, _event_bus: &EventBus, ) -> AppResult { + // 数据校验 + let fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?; + validate_data(&data, &fields)?; + let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?; let (sql, values) = DynamicTableManager::build_insert_sql(&table_name, tenant_id, operator_id, &data); @@ -53,7 +58,7 @@ impl PluginDataService { }) } - /// 列表查询 + /// 列表查询(支持过滤/搜索/排序) pub async fn list( plugin_id: Uuid, entity_name: &str, @@ -61,11 +66,30 @@ impl PluginDataService { page: u64, page_size: u64, db: &sea_orm::DatabaseConnection, + filter: Option, + search: Option, + sort_by: Option, + sort_order: Option, ) -> AppResult<(Vec, u64)> { let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?; - // Count - let (count_sql, count_values) = DynamicTableManager::build_count_sql(&table_name, tenant_id); + // 获取 searchable 字段列表 + let entity_fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?; + let search_tuple = { + let searchable: Vec<&str> = entity_fields + .iter() + .filter(|f| f.searchable == Some(true)) + .map(|f| f.name.as_str()) + .collect(); + match (searchable.is_empty(), &search) { + (false, Some(kw)) => Some((searchable.join(","), kw.clone())), + _ => None, + } + }; + + // Count(使用基础条件,不含 filter/search 以保持计数一致性) + let (count_sql, count_values) = + DynamicTableManager::build_count_sql(&table_name, tenant_id); #[derive(FromQueryResult)] struct CountResult { count: i64, @@ -80,9 +104,19 @@ impl PluginDataService { .map(|r| r.count as u64) .unwrap_or(0); - // Query - let offset = (page.saturating_sub(1)) * page_size; - let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_id, page_size, offset); + // Query(带过滤/搜索/排序) + let offset = page.saturating_sub(1) * page_size; + let (sql, values) = DynamicTableManager::build_filtered_query_sql( + &table_name, + tenant_id, + page_size, + offset, + filter, + search_tuple, + sort_by, + sort_order, + ) + .map_err(|e| AppError::Validation(e))?; #[derive(FromQueryResult)] struct DataRow { @@ -142,7 +176,7 @@ impl PluginDataService { )) .one(db) .await? - .ok_or_else(|| erp_core::error::AppError::NotFound("记录不存在".to_string()))?; + .ok_or_else(|| AppError::NotFound("记录不存在".to_string()))?; Ok(PluginDataResp { id: row.id.to_string(), @@ -165,6 +199,10 @@ impl PluginDataService { db: &sea_orm::DatabaseConnection, _event_bus: &EventBus, ) -> AppResult { + // 数据校验 + let fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?; + validate_data(&data, &fields)?; + let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?; let (sql, values) = DynamicTableManager::build_update_sql( &table_name, @@ -191,7 +229,7 @@ impl PluginDataService { )) .one(db) .await? - .ok_or_else(|| erp_core::error::AppError::VersionMismatch)?; + .ok_or_else(|| AppError::VersionMismatch)?; Ok(PluginDataResp { id: result.id.to_string(), @@ -240,11 +278,47 @@ async fn resolve_table_name( .one(db) .await? .ok_or_else(|| { - erp_core::error::AppError::NotFound(format!( - "插件实体 {}/{} 不存在", - plugin_id, entity_name - )) + AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name)) })?; Ok(entity.table_name) } + +/// 从 plugin_entities 表获取 entity 的字段定义 +async fn resolve_entity_fields( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> AppResult> { + let entity_model = plugin_entity::Entity::find() + .filter(plugin_entity::Column::PluginId.eq(plugin_id)) + .filter(plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(plugin_entity::Column::EntityName.eq(entity_name)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| { + AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name)) + })?; + + let entity_def: crate::manifest::PluginEntity = + serde_json::from_value(entity_model.schema_json) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + + Ok(entity_def.fields) +} + +/// 校验数据:检查 required 字段 +fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> { + let obj = data.as_object().ok_or_else(|| { + AppError::Validation("data 必须是 JSON 对象".to_string()) + })?; + for field in fields { + if field.required && !obj.contains_key(&field.name) { + let label = field.display_name.as_deref().unwrap_or(&field.name); + return Err(AppError::Validation(format!("字段 '{}' 不能为空", label))); + } + } + Ok(()) +} diff --git a/crates/erp-plugin/src/dynamic_table.rs b/crates/erp-plugin/src/dynamic_table.rs index 5026525..c7144ea 100644 --- a/crates/erp-plugin/src/dynamic_table.rs +++ b/crates/erp-plugin/src/dynamic_table.rs @@ -93,6 +93,24 @@ impl DynamicTableManager { } } + // 为 searchable 字段创建 B-tree 索引以加速 ILIKE 前缀查询 + for field in &entity.fields { + if field.searchable == Some(true) { + let sanitized_field = sanitize_identifier(&field.name); + let idx_name = format!("{}_{}_sidx", sanitize_identifier(&table_name), sanitized_field); + let idx_sql = format!( + "CREATE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL", + idx_name, table_name, sanitized_field + ); + db.execute(Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + idx_sql, + )) + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + } + } + tracing::info!(table = %table_name, "Dynamic table created"); Ok(()) } diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs index cc1d0b1..dd20bb1 100644 --- a/crates/erp-plugin/src/handler/data_handler.rs +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -11,6 +11,16 @@ use crate::data_dto::{CreatePluginDataReq, PluginDataListParams, PluginDataResp, use crate::data_service::PluginDataService; use crate::state::PluginState; +/// 计算插件数据操作所需的权限码 +/// 格式:{plugin_id}.{entity}.{action},如 crm.customer.list +fn compute_permission_code(plugin_id: &str, entity_name: &str, action: &str) -> String { + let action_suffix = match action { + "list" | "get" => "list", + _ => "manage", + }; + format!("{}.{}.{}", plugin_id, entity_name, action_suffix) +} + #[utoipa::path( get, path = "/api/v1/plugins/{plugin_id}/{entity}", @@ -32,11 +42,21 @@ where PluginState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "plugin.list")?; + // 动态权限检查:先尝试精细权限,回退到通用权限 + let fine_perm = compute_permission_code(&plugin_id.to_string(), &entity, "list"); + if require_permission(&ctx, &fine_perm).is_err() { + require_permission(&ctx, "plugin.list")?; + } let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); + // 解析 filter JSON + let filter: Option = params + .filter + .as_ref() + .and_then(|f| serde_json::from_str(f).ok()); + let (items, total) = PluginDataService::list( plugin_id, &entity, @@ -44,6 +64,10 @@ where page, page_size, &state.db, + filter, + params.search, + params.sort_by, + params.sort_order, ) .await?; @@ -77,7 +101,10 @@ where PluginState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "plugin.admin")?; + let fine_perm = compute_permission_code(&plugin_id.to_string(), &entity, "create"); + if require_permission(&ctx, &fine_perm).is_err() { + require_permission(&ctx, "plugin.admin")?; + } let result = PluginDataService::create( plugin_id, @@ -112,7 +139,10 @@ where PluginState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "plugin.list")?; + let fine_perm = compute_permission_code(&plugin_id.to_string(), &entity, "get"); + if require_permission(&ctx, &fine_perm).is_err() { + require_permission(&ctx, "plugin.list")?; + } let result = PluginDataService::get_by_id(plugin_id, &entity, id, ctx.tenant_id, &state.db).await?; @@ -141,7 +171,10 @@ where PluginState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "plugin.admin")?; + let fine_perm = compute_permission_code(&plugin_id.to_string(), &entity, "update"); + if require_permission(&ctx, &fine_perm).is_err() { + require_permission(&ctx, "plugin.admin")?; + } let result = PluginDataService::update( plugin_id, @@ -178,7 +211,10 @@ where PluginState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "plugin.admin")?; + let fine_perm = compute_permission_code(&plugin_id.to_string(), &entity, "delete"); + if require_permission(&ctx, &fine_perm).is_err() { + require_permission(&ctx, "plugin.admin")?; + } PluginDataService::delete( plugin_id,