Files
erp/crates/erp-plugin/src/dynamic_table.rs
iven 0ad77693f4 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 索引
2026-04-16 12:31:53 +08:00

496 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use sea_orm::{ConnectionTrait, DatabaseConnection, FromQueryResult, Statement, Value};
use uuid::Uuid;
use crate::error::{PluginError, PluginResult};
use crate::manifest::PluginEntity;
/// 消毒标识符:只保留 ASCII 字母、数字、下划线,防止 SQL 注入
fn sanitize_identifier(input: &str) -> String {
input
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
.collect()
}
/// 动态表管理器 — 处理插件动态创建/删除的数据库表
pub struct DynamicTableManager;
impl DynamicTableManager {
/// 生成动态表名: `plugin_{sanitized_id}_{sanitized_entity}`
pub fn table_name(plugin_id: &str, entity_name: &str) -> String {
let sanitized_id = sanitize_identifier(plugin_id);
let sanitized_entity = sanitize_identifier(entity_name);
format!("plugin_{}_{}", sanitized_id, sanitized_entity)
}
/// 创建动态表
pub async fn create_table(
db: &DatabaseConnection,
plugin_id: &str,
entity: &PluginEntity,
) -> PluginResult<()> {
let table_name = Self::table_name(plugin_id, &entity.name);
// 创建表
let create_sql = format!(
"CREATE TABLE IF NOT EXISTS \"{table_name}\" (\
\"id\" UUID PRIMARY KEY, \
\"tenant_id\" UUID NOT NULL, \
\"data\" JSONB NOT NULL DEFAULT '{{}}', \
\"created_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
\"updated_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
\"created_by\" UUID, \
\"updated_by\" UUID, \
\"deleted_at\" TIMESTAMPTZ, \
\"version\" INT NOT NULL DEFAULT 1)"
);
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
create_sql,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
// 创建租户索引
let tenant_idx_sql = format!(
"CREATE INDEX IF NOT EXISTS \"idx_{t}_tenant\" ON \"{table_name}\" (\"tenant_id\") WHERE \"deleted_at\" IS NULL",
t = sanitize_identifier(&table_name)
);
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
tenant_idx_sql,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
// 为字段创建索引(使用参数化方式避免 SQL 注入)
for field in &entity.fields {
if field.unique || field.required {
let sanitized_field = sanitize_identifier(&field.name);
let idx_name = format!(
"idx_{}_{}_{}",
sanitize_identifier(&table_name),
sanitized_field,
if field.unique { "uniq" } else { "idx" }
);
// unique 字段使用 CREATE UNIQUE INDEX由数据库保证数据完整性
let idx_sql = if field.unique {
format!(
"CREATE UNIQUE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" (\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
)
} else {
format!(
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" (\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
)
};
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
idx_sql,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
}
}
// 为 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(())
}
/// 删除动态表
pub async fn drop_table(
db: &DatabaseConnection,
plugin_id: &str,
entity_name: &str,
) -> PluginResult<()> {
let table_name = Self::table_name(plugin_id, entity_name);
let sql = format!("DROP TABLE IF EXISTS \"{}\"", table_name);
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
sql,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
tracing::info!(table = %table_name, "Dynamic table dropped");
Ok(())
}
/// 检查表是否存在
pub async fn table_exists(db: &DatabaseConnection, table_name: &str) -> PluginResult<bool> {
#[derive(FromQueryResult)]
struct ExistsResult {
exists: bool,
}
let result = ExistsResult::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)",
[table_name.into()],
))
.one(db)
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
Ok(result.map(|r| r.exists).unwrap_or(false))
}
/// 构建 INSERT SQL
pub fn build_insert_sql(
table_name: &str,
tenant_id: Uuid,
user_id: Uuid,
data: &serde_json::Value,
) -> (String, Vec<Value>) {
let id = Uuid::now_v7();
Self::build_insert_sql_with_id(table_name, id, tenant_id, user_id, data)
}
/// 构建 INSERT SQL指定 ID
pub fn build_insert_sql_with_id(
table_name: &str,
id: Uuid,
tenant_id: Uuid,
user_id: Uuid,
data: &serde_json::Value,
) -> (String, Vec<Value>) {
let sql = format!(
"INSERT INTO \"{}\" (id, tenant_id, data, created_by, updated_by, version) \
VALUES ($1, $2, $3, $4, $5, 1) \
RETURNING id, tenant_id, data, created_at, updated_at, version",
table_name
);
let values = vec![
id.into(),
tenant_id.into(),
serde_json::to_string(data).unwrap_or_default().into(),
user_id.into(),
user_id.into(),
];
(sql, values)
}
/// 构建 SELECT SQL
pub fn build_query_sql(
table_name: &str,
tenant_id: Uuid,
limit: u64,
offset: u64,
) -> (String, Vec<Value>) {
let sql = format!(
"SELECT id, data, created_at, updated_at, version \
FROM \"{}\" \
WHERE tenant_id = $1 AND deleted_at IS NULL \
ORDER BY created_at DESC \
LIMIT $2 OFFSET $3",
table_name
);
let values = vec![tenant_id.into(), (limit as i64).into(), (offset as i64).into()];
(sql, values)
}
/// 构建 COUNT SQL
pub fn build_count_sql(table_name: &str, tenant_id: Uuid) -> (String, Vec<Value>) {
let sql = format!(
"SELECT COUNT(*) as count FROM \"{}\" WHERE tenant_id = $1 AND deleted_at IS NULL",
table_name
);
let values = vec![tenant_id.into()];
(sql, values)
}
/// 构建 UPDATE SQL含乐观锁
pub fn build_update_sql(
table_name: &str,
id: Uuid,
tenant_id: Uuid,
user_id: Uuid,
data: &serde_json::Value,
version: i32,
) -> (String, Vec<Value>) {
let sql = format!(
"UPDATE \"{}\" \
SET data = $1, updated_at = NOW(), updated_by = $2, version = version + 1 \
WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \
RETURNING id, data, created_at, updated_at, version",
table_name
);
let values = vec![
serde_json::to_string(data).unwrap_or_default().into(),
user_id.into(),
id.into(),
tenant_id.into(),
version.into(),
];
(sql, values)
}
/// 构建 DELETE SQL软删除
pub fn build_delete_sql(
table_name: &str,
id: Uuid,
tenant_id: Uuid,
) -> (String, Vec<Value>) {
let sql = format!(
"UPDATE \"{}\" \
SET deleted_at = NOW(), updated_at = NOW() \
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
table_name
);
let values = vec![id.into(), tenant_id.into()];
(sql, values)
}
/// 构建单条查询 SQL
pub fn build_get_by_id_sql(
table_name: &str,
id: Uuid,
tenant_id: Uuid,
) -> (String, Vec<Value>) {
let sql = format!(
"SELECT id, data, created_at, updated_at, version \
FROM \"{}\" \
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
table_name
);
let values = vec![id.into(), tenant_id.into()];
(sql, values)
}
/// 构建唯一索引 SQL供测试验证
pub fn build_unique_index_sql(table_name: &str, field_name: &str) -> String {
let sanitized_field = sanitize_identifier(field_name);
let idx_name = format!(
"idx_{}_{}_uniq",
sanitize_identifier(table_name),
sanitized_field
);
format!(
"CREATE UNIQUE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL",
idx_name, table_name, sanitized_field
)
}
/// 构建带过滤条件的查询 SQL
pub fn build_filtered_query_sql(
table_name: &str,
tenant_id: Uuid,
limit: u64,
offset: u64,
filter: Option<serde_json::Value>,
search: Option<(String, String)>, // (searchable_fields_csv, keyword)
sort_by: Option<String>,
sort_order: Option<String>,
) -> Result<(String, Vec<Value>), String> {
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()];
// 处理 filter
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;
}
}
}
// 处理 search — 所有 searchable 字段共享同一个 ILIKE 参数
if let Some((fields_csv, keyword)) = search {
let escaped = keyword.replace('%', "\\%").replace('_', "\\_");
let fields: Vec<&str> = fields_csv.split(',').collect();
let search_param_idx = param_idx;
let search_conditions: Vec<String> = fields
.iter()
.map(|f| {
let clean = sanitize_identifier(f.trim());
format!("\"data\"->>'{}' ILIKE ${}", clean, search_param_idx)
})
.collect();
conditions.push(format!("({})", search_conditions.join(" OR ")));
values.push(Value::String(Some(Box::new(format!("%{}%", escaped)))));
param_idx += 1;
}
// 处理 sort
let order_clause = if let Some(sb) = sort_by {
let clean = sanitize_identifier(&sb);
if clean.is_empty() {
return Err(format!("无效的排序字段名: {}", sb));
}
let dir = match sort_order.as_deref() {
Some("asc") | Some("ASC") => "ASC",
_ => "DESC",
};
format!("ORDER BY \"data\"->>'{}' {}", clean, dir)
} else {
"ORDER BY \"created_at\" DESC".to_string()
};
let sql = format!(
"SELECT id, data, created_at, updated_at, version FROM \"{}\" WHERE {} {} LIMIT ${} OFFSET ${}",
table_name,
conditions.join(" AND "),
order_clause,
param_idx,
param_idx + 1,
);
values.push((limit as i64).into());
values.push((offset as i64).into());
Ok((sql, values))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unique_index_sql_uses_create_unique_index() {
let sql = DynamicTableManager::build_unique_index_sql("plugin_test", "code");
assert!(
sql.contains("CREATE UNIQUE INDEX"),
"Expected UNIQUE index, got: {}",
sql
);
}
#[test]
fn test_build_filtered_query_sql_with_filter() {
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
"plugin_test_customer",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20,
0,
Some(serde_json::json!({"customer_id": "abc-123"})),
None,
None,
None,
)
.unwrap();
assert!(
sql.contains("\"data\"->>'customer_id' ="),
"Expected filter in SQL, got: {}",
sql
);
assert!(sql.contains("tenant_id"), "Expected tenant_id filter");
// 验证参数值
assert_eq!(values.len(), 4); // tenant_id + filter_value + limit + offset
}
#[test]
fn test_build_filtered_query_sql_sanitizes_keys() {
let result = DynamicTableManager::build_filtered_query_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20,
0,
Some(serde_json::json!({"evil'; DROP TABLE--": "value"})),
None,
None,
None,
);
// 恶意 key 被清理为合法标识符
let (sql, _) = result.unwrap();
assert!(
!sql.contains("DROP TABLE"),
"SQL should not contain injection: {}",
sql
);
assert!(
sql.contains("evil___DROP_TABLE__"),
"Key should be sanitized: {}",
sql
);
}
#[test]
fn test_build_filtered_query_sql_with_search() {
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20,
0,
None,
Some(("name,code".to_string(), "测试关键词".to_string())),
None,
None,
)
.unwrap();
assert!(sql.contains("ILIKE"), "Expected ILIKE in SQL, got: {}", sql);
// 验证搜索参数值包含 %...%
if let Value::String(Some(s)) = &values[1] {
assert!(s.contains("测试关键词"), "Search value should contain keyword");
assert!(s.starts_with('%'), "Search value should start with %");
}
}
#[test]
fn test_build_filtered_query_sql_with_sort() {
let (sql, _) = DynamicTableManager::build_filtered_query_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20,
0,
None,
None,
Some("name".to_string()),
Some("asc".to_string()),
)
.unwrap();
assert!(
sql.contains("ORDER BY \"data\"->>'name' ASC"),
"Expected sort clause, got: {}",
sql
);
}
#[test]
fn test_build_filtered_query_sql_default_sort() {
let (sql, _) = DynamicTableManager::build_filtered_query_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20,
0,
None,
None,
None,
None,
)
.unwrap();
assert!(
sql.contains("ORDER BY \"created_at\" DESC"),
"Expected default sort, got: {}",
sql
);
}
}