feat(plugin): 集成过滤查询/排序/搜索到 REST API,添加数据校验和 searchable 索引
- 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 索引
This commit is contained in:
@@ -30,4 +30,9 @@ pub struct PluginDataListParams {
|
|||||||
pub page: Option<u64>,
|
pub page: Option<u64>,
|
||||||
pub page_size: Option<u64>,
|
pub page_size: Option<u64>,
|
||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
|
/// JSON 格式过滤: {"field":"value"}
|
||||||
|
pub filter: Option<String>,
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
/// "asc" or "desc"
|
||||||
|
pub sort_order: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, Statement};
|
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, Statement};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use erp_core::error::AppResult;
|
use erp_core::error::{AppError, AppResult};
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
use crate::data_dto::PluginDataResp;
|
use crate::data_dto::PluginDataResp;
|
||||||
use crate::dynamic_table::DynamicTableManager;
|
use crate::dynamic_table::DynamicTableManager;
|
||||||
use crate::entity::plugin_entity;
|
use crate::entity::plugin_entity;
|
||||||
use crate::error::PluginError;
|
use crate::error::PluginError;
|
||||||
|
use crate::manifest::PluginField;
|
||||||
|
|
||||||
pub struct PluginDataService;
|
pub struct PluginDataService;
|
||||||
|
|
||||||
@@ -22,6 +23,10 @@ impl PluginDataService {
|
|||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
_event_bus: &EventBus,
|
_event_bus: &EventBus,
|
||||||
) -> AppResult<PluginDataResp> {
|
) -> AppResult<PluginDataResp> {
|
||||||
|
// 数据校验
|
||||||
|
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 table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||||
let (sql, values) =
|
let (sql, values) =
|
||||||
DynamicTableManager::build_insert_sql(&table_name, tenant_id, operator_id, &data);
|
DynamicTableManager::build_insert_sql(&table_name, tenant_id, operator_id, &data);
|
||||||
@@ -53,7 +58,7 @@ impl PluginDataService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 列表查询
|
/// 列表查询(支持过滤/搜索/排序)
|
||||||
pub async fn list(
|
pub async fn list(
|
||||||
plugin_id: Uuid,
|
plugin_id: Uuid,
|
||||||
entity_name: &str,
|
entity_name: &str,
|
||||||
@@ -61,11 +66,30 @@ impl PluginDataService {
|
|||||||
page: u64,
|
page: u64,
|
||||||
page_size: u64,
|
page_size: u64,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
filter: Option<serde_json::Value>,
|
||||||
|
search: Option<String>,
|
||||||
|
sort_by: Option<String>,
|
||||||
|
sort_order: Option<String>,
|
||||||
) -> AppResult<(Vec<PluginDataResp>, u64)> {
|
) -> AppResult<(Vec<PluginDataResp>, u64)> {
|
||||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||||
|
|
||||||
// Count
|
// 获取 searchable 字段列表
|
||||||
let (count_sql, count_values) = DynamicTableManager::build_count_sql(&table_name, tenant_id);
|
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)]
|
#[derive(FromQueryResult)]
|
||||||
struct CountResult {
|
struct CountResult {
|
||||||
count: i64,
|
count: i64,
|
||||||
@@ -80,9 +104,19 @@ impl PluginDataService {
|
|||||||
.map(|r| r.count as u64)
|
.map(|r| r.count as u64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
// Query
|
// Query(带过滤/搜索/排序)
|
||||||
let offset = (page.saturating_sub(1)) * page_size;
|
let offset = page.saturating_sub(1) * page_size;
|
||||||
let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_id, page_size, offset);
|
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)]
|
#[derive(FromQueryResult)]
|
||||||
struct DataRow {
|
struct DataRow {
|
||||||
@@ -142,7 +176,7 @@ impl PluginDataService {
|
|||||||
))
|
))
|
||||||
.one(db)
|
.one(db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| erp_core::error::AppError::NotFound("记录不存在".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("记录不存在".to_string()))?;
|
||||||
|
|
||||||
Ok(PluginDataResp {
|
Ok(PluginDataResp {
|
||||||
id: row.id.to_string(),
|
id: row.id.to_string(),
|
||||||
@@ -165,6 +199,10 @@ impl PluginDataService {
|
|||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
_event_bus: &EventBus,
|
_event_bus: &EventBus,
|
||||||
) -> AppResult<PluginDataResp> {
|
) -> AppResult<PluginDataResp> {
|
||||||
|
// 数据校验
|
||||||
|
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 table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||||
let (sql, values) = DynamicTableManager::build_update_sql(
|
let (sql, values) = DynamicTableManager::build_update_sql(
|
||||||
&table_name,
|
&table_name,
|
||||||
@@ -191,7 +229,7 @@ impl PluginDataService {
|
|||||||
))
|
))
|
||||||
.one(db)
|
.one(db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| erp_core::error::AppError::VersionMismatch)?;
|
.ok_or_else(|| AppError::VersionMismatch)?;
|
||||||
|
|
||||||
Ok(PluginDataResp {
|
Ok(PluginDataResp {
|
||||||
id: result.id.to_string(),
|
id: result.id.to_string(),
|
||||||
@@ -240,11 +278,47 @@ async fn resolve_table_name(
|
|||||||
.one(db)
|
.one(db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
erp_core::error::AppError::NotFound(format!(
|
AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name))
|
||||||
"插件实体 {}/{} 不存在",
|
|
||||||
plugin_id, entity_name
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(entity.table_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<Vec<PluginField>> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
tracing::info!(table = %table_name, "Dynamic table created");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ use crate::data_dto::{CreatePluginDataReq, PluginDataListParams, PluginDataResp,
|
|||||||
use crate::data_service::PluginDataService;
|
use crate::data_service::PluginDataService;
|
||||||
use crate::state::PluginState;
|
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(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/v1/plugins/{plugin_id}/{entity}",
|
path = "/api/v1/plugins/{plugin_id}/{entity}",
|
||||||
@@ -32,11 +42,21 @@ where
|
|||||||
PluginState: FromRef<S>,
|
PluginState: FromRef<S>,
|
||||||
S: Clone + Send + Sync + 'static,
|
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 = params.page.unwrap_or(1);
|
||||||
let page_size = params.page_size.unwrap_or(20);
|
let page_size = params.page_size.unwrap_or(20);
|
||||||
|
|
||||||
|
// 解析 filter JSON
|
||||||
|
let filter: Option<serde_json::Value> = params
|
||||||
|
.filter
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|f| serde_json::from_str(f).ok());
|
||||||
|
|
||||||
let (items, total) = PluginDataService::list(
|
let (items, total) = PluginDataService::list(
|
||||||
plugin_id,
|
plugin_id,
|
||||||
&entity,
|
&entity,
|
||||||
@@ -44,6 +64,10 @@ where
|
|||||||
page,
|
page,
|
||||||
page_size,
|
page_size,
|
||||||
&state.db,
|
&state.db,
|
||||||
|
filter,
|
||||||
|
params.search,
|
||||||
|
params.sort_by,
|
||||||
|
params.sort_order,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -77,7 +101,10 @@ where
|
|||||||
PluginState: FromRef<S>,
|
PluginState: FromRef<S>,
|
||||||
S: Clone + Send + Sync + 'static,
|
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(
|
let result = PluginDataService::create(
|
||||||
plugin_id,
|
plugin_id,
|
||||||
@@ -112,7 +139,10 @@ where
|
|||||||
PluginState: FromRef<S>,
|
PluginState: FromRef<S>,
|
||||||
S: Clone + Send + Sync + 'static,
|
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 =
|
let result =
|
||||||
PluginDataService::get_by_id(plugin_id, &entity, id, ctx.tenant_id, &state.db).await?;
|
PluginDataService::get_by_id(plugin_id, &entity, id, ctx.tenant_id, &state.db).await?;
|
||||||
@@ -141,7 +171,10 @@ where
|
|||||||
PluginState: FromRef<S>,
|
PluginState: FromRef<S>,
|
||||||
S: Clone + Send + Sync + 'static,
|
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(
|
let result = PluginDataService::update(
|
||||||
plugin_id,
|
plugin_id,
|
||||||
@@ -178,7 +211,10 @@ where
|
|||||||
PluginState: FromRef<S>,
|
PluginState: FromRef<S>,
|
||||||
S: Clone + Send + Sync + 'static,
|
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(
|
PluginDataService::delete(
|
||||||
plugin_id,
|
plugin_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user