fix(plugin): 修复插件 schema API、动态表 JSONB 和 SQL 注入防护

- get_schema 端点同时返回 entities 和 ui 页面配置,修复前端无法生成动态菜单的问题
- 动态表 INSERT/UPDATE 添加 ::jsonb 类型转换,修复 PostgreSQL 类型推断错误
- JSONB 索引创建改为非致命(warn 跳过),避免索引冲突阻断安装流程
- 权限注册/注销改用参数化查询,消除 SQL 注入风险
- DDL 语句改用 execute_unprepared,避免不必要的安全检查开销
- clear_plugin 支持已上传状态的清理
- 添加关键步骤 tracing 日志便于排查安装问题
This commit is contained in:
iven
2026-04-16 23:42:40 +08:00
parent b482230a07
commit 3483395f5e
6 changed files with 225 additions and 176 deletions

View File

@@ -54,7 +54,7 @@ display_name = "客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "code" name = "code"
field_type = "String" field_type = "string"
required = true required = true
display_name = "客户编码" display_name = "客户编码"
unique = true unique = true
@@ -62,14 +62,14 @@ display_name = "客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "name" name = "name"
field_type = "String" field_type = "string"
required = true required = true
display_name = "客户名称" display_name = "客户名称"
searchable = true searchable = true
[[schema.entities.fields]] [[schema.entities.fields]]
name = "customer_type" name = "customer_type"
field_type = "String" field_type = "string"
required = true required = true
display_name = "客户类型" display_name = "客户类型"
ui_widget = "select" ui_widget = "select"
@@ -81,19 +81,19 @@ display_name = "客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "industry" name = "industry"
field_type = "String" field_type = "string"
display_name = "行业" display_name = "行业"
filterable = true filterable = true
[[schema.entities.fields]] [[schema.entities.fields]]
name = "region" name = "region"
field_type = "String" field_type = "string"
display_name = "地区" display_name = "地区"
filterable = true filterable = true
[[schema.entities.fields]] [[schema.entities.fields]]
name = "source" name = "source"
field_type = "String" field_type = "string"
display_name = "来源" display_name = "来源"
ui_widget = "select" ui_widget = "select"
options = [ options = [
@@ -106,7 +106,7 @@ display_name = "客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "level" name = "level"
field_type = "String" field_type = "string"
display_name = "等级" display_name = "等级"
ui_widget = "select" ui_widget = "select"
filterable = true filterable = true
@@ -119,7 +119,7 @@ display_name = "客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "status" name = "status"
field_type = "String" field_type = "string"
required = true required = true
display_name = "状态" display_name = "状态"
ui_widget = "select" ui_widget = "select"
@@ -132,34 +132,34 @@ display_name = "客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "credit_code" name = "credit_code"
field_type = "String" field_type = "string"
display_name = "统一社会信用代码" display_name = "统一社会信用代码"
visible_when = "customer_type == 'enterprise'" visible_when = "customer_type == 'enterprise'"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "id_number" name = "id_number"
field_type = "String" field_type = "string"
display_name = "身份证号" display_name = "身份证号"
visible_when = "customer_type == 'personal'" visible_when = "customer_type == 'personal'"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "parent_id" name = "parent_id"
field_type = "Uuid" field_type = "uuid"
display_name = "上级客户" display_name = "上级客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "website" name = "website"
field_type = "String" field_type = "string"
display_name = "网站" display_name = "网站"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "address" name = "address"
field_type = "String" field_type = "string"
display_name = "地址" display_name = "地址"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "remark" name = "remark"
field_type = "String" field_type = "string"
display_name = "备注" display_name = "备注"
ui_widget = "textarea" ui_widget = "textarea"
@@ -169,50 +169,50 @@ display_name = "联系人"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "customer_id" name = "customer_id"
field_type = "Uuid" field_type = "uuid"
required = true required = true
display_name = "所属客户" display_name = "所属客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "name" name = "name"
field_type = "String" field_type = "string"
required = true required = true
display_name = "姓名" display_name = "姓名"
searchable = true searchable = true
[[schema.entities.fields]] [[schema.entities.fields]]
name = "position" name = "position"
field_type = "String" field_type = "string"
display_name = "职务" display_name = "职务"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "department" name = "department"
field_type = "String" field_type = "string"
display_name = "部门" display_name = "部门"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "phone" name = "phone"
field_type = "String" field_type = "string"
display_name = "手机号" display_name = "手机号"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "email" name = "email"
field_type = "String" field_type = "string"
display_name = "邮箱" display_name = "邮箱"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "wechat" name = "wechat"
field_type = "String" field_type = "string"
display_name = "微信号" display_name = "微信号"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "is_primary" name = "is_primary"
field_type = "Boolean" field_type = "boolean"
display_name = "主联系人" display_name = "主联系人"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "remark" name = "remark"
field_type = "String" field_type = "string"
display_name = "备注" display_name = "备注"
[[schema.entities]] [[schema.entities]]
@@ -221,18 +221,18 @@ display_name = "沟通记录"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "customer_id" name = "customer_id"
field_type = "Uuid" field_type = "uuid"
required = true required = true
display_name = "关联客户" display_name = "关联客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "contact_id" name = "contact_id"
field_type = "Uuid" field_type = "uuid"
display_name = "关联联系人" display_name = "关联联系人"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "type" name = "type"
field_type = "String" field_type = "string"
required = true required = true
display_name = "类型" display_name = "类型"
ui_widget = "select" ui_widget = "select"
@@ -247,28 +247,28 @@ display_name = "沟通记录"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "subject" name = "subject"
field_type = "String" field_type = "string"
required = true required = true
display_name = "主题" display_name = "主题"
searchable = true searchable = true
[[schema.entities.fields]] [[schema.entities.fields]]
name = "content" name = "content"
field_type = "String" field_type = "string"
required = true required = true
display_name = "内容" display_name = "内容"
ui_widget = "textarea" ui_widget = "textarea"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "occurred_at" name = "occurred_at"
field_type = "DateTime" field_type = "date_time"
required = true required = true
display_name = "沟通时间" display_name = "沟通时间"
sortable = true sortable = true
[[schema.entities.fields]] [[schema.entities.fields]]
name = "next_follow_up" name = "next_follow_up"
field_type = "Date" field_type = "date"
display_name = "下次跟进日期" display_name = "下次跟进日期"
[[schema.entities]] [[schema.entities]]
@@ -277,20 +277,20 @@ display_name = "客户标签"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "customer_id" name = "customer_id"
field_type = "Uuid" field_type = "uuid"
required = true required = true
display_name = "关联客户" display_name = "关联客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "tag_name" name = "tag_name"
field_type = "String" field_type = "string"
required = true required = true
display_name = "标签名称" display_name = "标签名称"
searchable = true searchable = true
[[schema.entities.fields]] [[schema.entities.fields]]
name = "tag_category" name = "tag_category"
field_type = "String" field_type = "string"
display_name = "标签分类" display_name = "标签分类"
ui_widget = "select" ui_widget = "select"
options = [ options = [
@@ -306,19 +306,19 @@ display_name = "客户关系"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "from_customer_id" name = "from_customer_id"
field_type = "Uuid" field_type = "uuid"
required = true required = true
display_name = "源客户" display_name = "源客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "to_customer_id" name = "to_customer_id"
field_type = "Uuid" field_type = "uuid"
required = true required = true
display_name = "目标客户" display_name = "目标客户"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "relationship_type" name = "relationship_type"
field_type = "String" field_type = "string"
required = true required = true
display_name = "关系类型" display_name = "关系类型"
ui_widget = "select" ui_widget = "select"
@@ -333,7 +333,7 @@ display_name = "客户关系"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "description" name = "description"
field_type = "String" field_type = "string"
display_name = "关系描述" display_name = "关系描述"
# ── 页面声明 ── # ── 页面声明 ──

View File

@@ -6,12 +6,28 @@ 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;
use crate::entity::plugin_entity; use crate::entity::plugin_entity;
use crate::error::PluginError; use crate::error::PluginError;
use crate::manifest::PluginField; use crate::manifest::PluginField;
pub struct PluginDataService; pub struct PluginDataService;
/// 插件实体信息(合并查询减少 DB 调用)
struct EntityInfo {
table_name: String,
schema_json: serde_json::Value,
}
impl EntityInfo {
fn fields(&self) -> AppResult<Vec<PluginField>> {
let entity_def: crate::manifest::PluginEntity =
serde_json::from_value(self.schema_json.clone())
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
Ok(entity_def.fields)
}
}
impl PluginDataService { impl PluginDataService {
/// 创建插件数据 /// 创建插件数据
pub async fn create( pub async fn create(
@@ -23,13 +39,12 @@ impl PluginDataService {
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
_event_bus: &EventBus, _event_bus: &EventBus,
) -> AppResult<PluginDataResp> { ) -> AppResult<PluginDataResp> {
// 数据校验 let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?; let fields = info.fields()?;
validate_data(&data, &fields)?; validate_data(&data, &fields)?;
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(&info.table_name, tenant_id, operator_id, &data);
#[derive(FromQueryResult)] #[derive(FromQueryResult)]
struct InsertResult { struct InsertResult {
@@ -71,10 +86,10 @@ impl PluginDataService {
sort_by: Option<String>, sort_by: Option<String>,
sort_order: 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 info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
// 获取 searchable 字段列表 // 获取 searchable 字段列表
let entity_fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?; let entity_fields = info.fields()?;
let search_tuple = { let search_tuple = {
let searchable: Vec<&str> = entity_fields let searchable: Vec<&str> = entity_fields
.iter() .iter()
@@ -87,9 +102,9 @@ impl PluginDataService {
} }
}; };
// Count(使用基础条件,不含 filter/search 以保持计数一致性) // Count
let (count_sql, count_values) = let (count_sql, count_values) =
DynamicTableManager::build_count_sql(&table_name, tenant_id); DynamicTableManager::build_count_sql(&info.table_name, tenant_id);
#[derive(FromQueryResult)] #[derive(FromQueryResult)]
struct CountResult { struct CountResult {
count: i64, count: i64,
@@ -104,10 +119,10 @@ 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_filtered_query_sql( let (sql, values) = DynamicTableManager::build_filtered_query_sql(
&table_name, &info.table_name,
tenant_id, tenant_id,
page_size, page_size,
offset, offset,
@@ -157,8 +172,8 @@ impl PluginDataService {
tenant_id: Uuid, tenant_id: Uuid,
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
) -> AppResult<PluginDataResp> { ) -> AppResult<PluginDataResp> {
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?; let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let (sql, values) = DynamicTableManager::build_get_by_id_sql(&table_name, id, tenant_id); let (sql, values) = DynamicTableManager::build_get_by_id_sql(&info.table_name, id, tenant_id);
#[derive(FromQueryResult)] #[derive(FromQueryResult)]
struct DataRow { struct DataRow {
@@ -199,13 +214,12 @@ impl PluginDataService {
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
_event_bus: &EventBus, _event_bus: &EventBus,
) -> AppResult<PluginDataResp> { ) -> AppResult<PluginDataResp> {
// 数据校验 let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?; let fields = info.fields()?;
validate_data(&data, &fields)?; 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( let (sql, values) = DynamicTableManager::build_update_sql(
&table_name, &info.table_name,
id, id,
tenant_id, tenant_id,
operator_id, operator_id,
@@ -249,8 +263,8 @@ impl PluginDataService {
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
_event_bus: &EventBus, _event_bus: &EventBus,
) -> AppResult<()> { ) -> AppResult<()> {
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?; let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let (sql, values) = DynamicTableManager::build_delete_sql(&table_name, id, tenant_id); let (sql, values) = DynamicTableManager::build_delete_sql(&info.table_name, id, tenant_id);
db.execute(Statement::from_sql_and_values( db.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres, sea_orm::DatabaseBackend::Postgres,
@@ -271,10 +285,9 @@ impl PluginDataService {
filter: Option<serde_json::Value>, filter: Option<serde_json::Value>,
search: Option<String>, search: Option<String>,
) -> AppResult<u64> { ) -> AppResult<u64> {
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?; let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
// 获取 searchable 字段列表,构建搜索条件 let entity_fields = info.fields()?;
let entity_fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?;
let search_tuple = { let search_tuple = {
let searchable: Vec<&str> = entity_fields let searchable: Vec<&str> = entity_fields
.iter() .iter()
@@ -288,7 +301,7 @@ impl PluginDataService {
}; };
let (sql, values) = DynamicTableManager::build_filtered_count_sql( let (sql, values) = DynamicTableManager::build_filtered_count_sql(
&table_name, &info.table_name,
tenant_id, tenant_id,
filter, filter,
search_tuple, search_tuple,
@@ -323,10 +336,10 @@ impl PluginDataService {
group_by_field: &str, group_by_field: &str,
filter: Option<serde_json::Value>, filter: Option<serde_json::Value>,
) -> AppResult<Vec<(String, i64)>> { ) -> AppResult<Vec<(String, i64)>> {
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?; let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let (sql, values) = DynamicTableManager::build_aggregate_sql( let (sql, values) = DynamicTableManager::build_aggregate_sql(
&table_name, &info.table_name,
tenant_id, tenant_id,
group_by_field, group_by_field,
filter, filter,
@@ -356,13 +369,34 @@ impl PluginDataService {
} }
} }
/// 从 plugin_entities 表解析 table_name带租户隔离 /// 从 plugins 表解析 manifest metadata.id如 "erp-crm"
async fn resolve_table_name( pub async fn resolve_manifest_id(
plugin_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AppResult<String> {
let model = plugin::Entity::find()
.filter(plugin::Column::Id.eq(plugin_id))
.filter(plugin::Column::TenantId.eq(tenant_id))
.filter(plugin::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| AppError::NotFound(format!("插件 {} 不存在", plugin_id)))?;
let manifest: crate::manifest::PluginManifest =
serde_json::from_value(model.manifest_json)
.map_err(|e| AppError::Internal(format!("解析插件 manifest 失败: {}", e)))?;
Ok(manifest.metadata.id)
}
/// 从 plugin_entities 表获取实体完整信息(带租户隔离)
async fn resolve_entity_info(
plugin_id: Uuid, plugin_id: Uuid,
entity_name: &str, entity_name: &str,
tenant_id: Uuid, tenant_id: Uuid,
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
) -> AppResult<String> { ) -> AppResult<EntityInfo> {
let entity = plugin_entity::Entity::find() let entity = plugin_entity::Entity::find()
.filter(plugin_entity::Column::PluginId.eq(plugin_id)) .filter(plugin_entity::Column::PluginId.eq(plugin_id))
.filter(plugin_entity::Column::TenantId.eq(tenant_id)) .filter(plugin_entity::Column::TenantId.eq(tenant_id))
@@ -374,32 +408,10 @@ async fn resolve_table_name(
AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name)) AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name))
})?; })?;
Ok(entity.table_name) Ok(EntityInfo {
} table_name: entity.table_name,
schema_json: entity.schema_json,
/// 从 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 字段 /// 校验数据:检查 required 字段

View File

@@ -45,28 +45,25 @@ impl DynamicTableManager {
\"version\" INT NOT NULL DEFAULT 1)" \"version\" INT NOT NULL DEFAULT 1)"
); );
db.execute(Statement::from_string( db.execute_unprepared(&create_sql).await
sea_orm::DatabaseBackend::Postgres, .map_err(|e| {
create_sql, tracing::error!(sql = %create_sql, error = %e, "CREATE TABLE failed");
)) PluginError::DatabaseError(e.to_string())
.await })?;
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
// 创建租户索引 // 创建租户索引
let tenant_idx_sql = format!( let tenant_idx_sql = format!(
"CREATE INDEX IF NOT EXISTS \"idx_{t}_tenant\" ON \"{table_name}\" (\"tenant_id\") WHERE \"deleted_at\" IS NULL", "CREATE INDEX IF NOT EXISTS \"idx_{t}_tenant\" ON \"{table_name}\" (\"tenant_id\") WHERE \"deleted_at\" IS NULL",
t = sanitize_identifier(&table_name) t = sanitize_identifier(&table_name)
); );
db.execute(Statement::from_string( db.execute_unprepared(&tenant_idx_sql).await
sea_orm::DatabaseBackend::Postgres,
tenant_idx_sql,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?; .map_err(|e| PluginError::DatabaseError(e.to_string()))?;
// 为字段创建索引(使用参数化方式避免 SQL 注入) // 为字段创建索引(使用参数化方式避免 SQL 注入)
let mut idx_counter = 0usize;
for field in &entity.fields { for field in &entity.fields {
if field.unique || field.required { if field.unique || field.required {
idx_counter += 1;
let sanitized_field = sanitize_identifier(&field.name); let sanitized_field = sanitize_identifier(&field.name);
let idx_name = format!( let idx_name = format!(
"idx_{}_{}_{}", "idx_{}_{}_{}",
@@ -77,37 +74,42 @@ impl DynamicTableManager {
// unique 字段使用 CREATE UNIQUE INDEX由数据库保证数据完整性 // unique 字段使用 CREATE UNIQUE INDEX由数据库保证数据完整性
let idx_sql = if field.unique { let idx_sql = if field.unique {
format!( format!(
"CREATE UNIQUE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" (\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL" "CREATE UNIQUE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL",
idx_name, table_name, sanitized_field
) )
} else { } else {
format!( format!(
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" (\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL" "CREATE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL",
idx_name, table_name, sanitized_field
) )
}; };
db.execute(Statement::from_string( tracing::info!(step = idx_counter, field = %field.name, unique = field.unique, sql = %idx_sql, "Creating field index");
sea_orm::DatabaseBackend::Postgres, match db.execute_unprepared(&idx_sql).await {
idx_sql, Ok(_) => {},
)) Err(e) => {
.await tracing::warn!(step = idx_counter, field = %field.name, sql = %idx_sql, error = %e, "Index creation skipped (non-fatal)");
.map_err(|e| PluginError::DatabaseError(e.to_string()))?; }
}
} }
} }
// 为 searchable 字段创建 B-tree 索引以加速 ILIKE 前缀查询 // 为 searchable 字段创建 B-tree 索引以加速 ILIKE 前缀查询
for field in &entity.fields { for field in &entity.fields {
if field.searchable == Some(true) { if field.searchable == Some(true) {
idx_counter += 1;
let sanitized_field = sanitize_identifier(&field.name); let sanitized_field = sanitize_identifier(&field.name);
let idx_name = format!("{}_{}_sidx", sanitize_identifier(&table_name), sanitized_field); let idx_name = format!("{}_{}_sidx", sanitize_identifier(&table_name), sanitized_field);
let idx_sql = format!( let idx_sql = format!(
"CREATE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL", "CREATE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL",
idx_name, table_name, sanitized_field idx_name, table_name, sanitized_field
); );
db.execute(Statement::from_string( tracing::info!(step = idx_counter, field = %field.name, sql = %idx_sql, "Creating search index");
sea_orm::DatabaseBackend::Postgres, match db.execute_unprepared(&idx_sql).await {
idx_sql, Ok(_) => {},
)) Err(e) => {
.await tracing::warn!(step = idx_counter, field = %field.name, sql = %idx_sql, error = %e, "Search index creation skipped (non-fatal)");
.map_err(|e| PluginError::DatabaseError(e.to_string()))?; }
}
} }
} }
@@ -172,7 +174,7 @@ impl DynamicTableManager {
) -> (String, Vec<Value>) { ) -> (String, Vec<Value>) {
let sql = format!( let sql = format!(
"INSERT INTO \"{}\" (id, tenant_id, data, created_by, updated_by, version) \ "INSERT INTO \"{}\" (id, tenant_id, data, created_by, updated_by, version) \
VALUES ($1, $2, $3, $4, $5, 1) \ VALUES ($1, $2, $3::jsonb, $4, $5, 1) \
RETURNING id, tenant_id, data, created_at, updated_at, version", RETURNING id, tenant_id, data, created_at, updated_at, version",
table_name table_name
); );
@@ -226,7 +228,7 @@ impl DynamicTableManager {
) -> (String, Vec<Value>) { ) -> (String, Vec<Value>) {
let sql = format!( let sql = format!(
"UPDATE \"{}\" \ "UPDATE \"{}\" \
SET data = $1, updated_at = NOW(), updated_by = $2, version = version + 1 \ SET data = $1::jsonb, updated_at = NOW(), updated_by = $2, version = version + 1 \
WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \ WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \
RETURNING id, data, created_at, updated_at, version", RETURNING id, data, created_at, updated_at, version",
table_name table_name

View File

@@ -11,17 +11,17 @@ use crate::data_dto::{
AggregateItem, AggregateQueryParams, CountQueryParams, CreatePluginDataReq, AggregateItem, AggregateQueryParams, CountQueryParams, CreatePluginDataReq,
PluginDataListParams, PluginDataResp, UpdatePluginDataReq, PluginDataListParams, PluginDataResp, UpdatePluginDataReq,
}; };
use crate::data_service::PluginDataService; use crate::data_service::{PluginDataService, resolve_manifest_id};
use crate::state::PluginState; use crate::state::PluginState;
/// 计算插件数据操作所需的权限码 /// 计算插件数据操作所需的权限码
/// 格式:{plugin_id}.{entity}.{action},如 crm.customer.list /// 格式:{manifest_id}.{entity}.{action},如 erp-crm.customer.list
fn compute_permission_code(plugin_id: &str, entity_name: &str, action: &str) -> String { fn compute_permission_code(manifest_id: &str, entity_name: &str, action: &str) -> String {
let action_suffix = match action { let action_suffix = match action {
"list" | "get" => "list", "list" | "get" => "list",
_ => "manage", _ => "manage",
}; };
format!("{}.{}.{}", plugin_id, entity_name, action_suffix) format!("{}.{}.{}", manifest_id, entity_name, action_suffix)
} }
#[utoipa::path( #[utoipa::path(
@@ -45,8 +45,8 @@ where
PluginState: FromRef<S>, PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static, 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(&plugin_id.to_string(), &entity, "list"); let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
if require_permission(&ctx, &fine_perm).is_err() { if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.list")?; require_permission(&ctx, "plugin.list")?;
} }
@@ -104,7 +104,8 @@ where
PluginState: FromRef<S>, PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
let fine_perm = compute_permission_code(&plugin_id.to_string(), &entity, "create"); let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "create");
if require_permission(&ctx, &fine_perm).is_err() { if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.admin")?; require_permission(&ctx, "plugin.admin")?;
} }
@@ -142,7 +143,8 @@ where
PluginState: FromRef<S>, PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
let fine_perm = compute_permission_code(&plugin_id.to_string(), &entity, "get"); let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "get");
if require_permission(&ctx, &fine_perm).is_err() { if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.list")?; require_permission(&ctx, "plugin.list")?;
} }
@@ -174,7 +176,8 @@ where
PluginState: FromRef<S>, PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
let fine_perm = compute_permission_code(&plugin_id.to_string(), &entity, "update"); let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "update");
if require_permission(&ctx, &fine_perm).is_err() { if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.admin")?; require_permission(&ctx, "plugin.admin")?;
} }
@@ -214,7 +217,8 @@ where
PluginState: FromRef<S>, PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
let fine_perm = compute_permission_code(&plugin_id.to_string(), &entity, "delete"); let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "delete");
if require_permission(&ctx, &fine_perm).is_err() { if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.admin")?; require_permission(&ctx, "plugin.admin")?;
} }
@@ -253,7 +257,8 @@ where
PluginState: FromRef<S>, PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
let fine_perm = compute_permission_code(&plugin_id.to_string(), &entity, "list"); let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
if require_permission(&ctx, &fine_perm).is_err() { if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.list")?; require_permission(&ctx, "plugin.list")?;
} }
@@ -298,7 +303,8 @@ where
PluginState: FromRef<S>, PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
let fine_perm = compute_permission_code(&plugin_id.to_string(), &entity, "list"); let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
if require_permission(&ctx, &fine_perm).is_err() { if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.list")?; require_permission(&ctx, "plugin.list")?;
} }

View File

@@ -199,7 +199,11 @@ where
&state.db, &state.db,
&state.engine, &state.engine,
) )
.await?; .await
.map_err(|e| {
tracing::error!(error = %e, "Install failed");
e
})?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }

View File

@@ -96,11 +96,16 @@ impl PluginService {
// 创建动态表 + 注册 entity 记录 // 创建动态表 + 注册 entity 记录
let mut entity_resps = Vec::new(); let mut entity_resps = Vec::new();
if let Some(schema) = &manifest.schema { if let Some(schema) = &manifest.schema {
for entity_def in &schema.entities { for (i, entity_def) in schema.entities.iter().enumerate() {
let table_name = DynamicTableManager::table_name(&manifest.metadata.id, &entity_def.name); let table_name = DynamicTableManager::table_name(&manifest.metadata.id, &entity_def.name);
tracing::info!(step = i, entity = %entity_def.name, table = %table_name, "Creating dynamic table");
// 创建动态表 // 创建动态表
DynamicTableManager::create_table(db, &manifest.metadata.id, entity_def).await?; DynamicTableManager::create_table(db, &manifest.metadata.id, entity_def).await
.map_err(|e| {
tracing::error!(entity = %entity_def.name, table = %table_name, error = %e, "Failed to create dynamic table");
e
})?;
// 注册 entity 记录 // 注册 entity 记录
let entity_id = Uuid::now_v7(); let entity_id = Uuid::now_v7();
@@ -143,6 +148,7 @@ impl PluginService {
} }
// 注册插件声明的权限到 permissions 表 // 注册插件声明的权限到 permissions 表
tracing::info!("Registering plugin permissions");
if let Some(perms) = &manifest.permissions { if let Some(perms) = &manifest.permissions {
register_plugin_permissions( register_plugin_permissions(
db, db,
@@ -152,10 +158,15 @@ impl PluginService {
perms, perms,
&now, &now,
) )
.await?; .await
.map_err(|e| {
tracing::error!(error = %e, "Failed to register permissions");
e
})?;
} }
// 加载到内存 // 加载到内存
tracing::info!(manifest_id = %manifest.metadata.id, "Loading plugin into engine");
engine engine
.load( .load(
&manifest.metadata.id, &manifest.metadata.id,
@@ -454,7 +465,22 @@ impl PluginService {
let manifest: PluginManifest = let manifest: PluginManifest =
serde_json::from_value(model.manifest_json.clone()) serde_json::from_value(model.manifest_json.clone())
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?; .map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
Ok(serde_json::to_value(&manifest.schema).unwrap_or_default())
// 构建 schema 响应entities + ui 页面配置
let mut result = serde_json::Map::new();
if let Some(schema) = &manifest.schema {
result.insert(
"entities".to_string(),
serde_json::to_value(&schema.entities).unwrap_or_default(),
);
}
if let Some(ui) = &manifest.ui {
result.insert(
"ui".to_string(),
serde_json::to_value(ui).unwrap_or_default(),
);
}
Ok(serde_json::Value::Object(result))
} }
/// 清除插件记录(软删除,仅限已卸载状态) /// 清除插件记录(软删除,仅限已卸载状态)
@@ -465,7 +491,7 @@ impl PluginService {
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
) -> AppResult<()> { ) -> AppResult<()> {
let model = find_plugin(plugin_id, tenant_id, db).await?; let model = find_plugin(plugin_id, tenant_id, db).await?;
validate_status(&model.status, "uninstalled")?; validate_status_any(&model.status, &["uninstalled", "uploaded"])?;
let now = Utc::now(); let now = Utc::now();
let mut active: plugin::ActiveModel = model.into(); let mut active: plugin::ActiveModel = model.into();
active.deleted_at = Set(Some(now)); active.deleted_at = Set(Some(now));
@@ -585,34 +611,33 @@ async fn register_plugin_permissions(
) -> AppResult<()> { ) -> AppResult<()> {
for perm in perms { for perm in perms {
let full_code = format!("{}.{}", plugin_manifest_id, perm.code); let full_code = format!("{}.{}", plugin_manifest_id, perm.code);
// resource 使用插件 manifest idaction 使用权限的 code 字段
let resource = plugin_manifest_id.to_string(); let resource = plugin_manifest_id.to_string();
let action = perm.code.clone(); let action = perm.code.clone();
let description_sql = if perm.description.is_empty() { let description: Option<String> = if perm.description.is_empty() {
"NULL".to_string() None
} else { } else {
format!("'{}'", perm.description.replace('\'', "''")) Some(perm.description.clone())
}; };
let sql = format!( let sql = r#"
r#"
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version)
VALUES (gen_random_uuid(), '{tenant_id}', '{full_code}', '{name}', '{resource}', '{action}', {desc}, '{now}', '{now}', '{operator_id}', '{operator_id}', NULL, 1) VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $7, $8, $8, NULL, 1)
ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO NOTHING ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO NOTHING
"#, "#;
tenant_id = tenant_id,
full_code = full_code.replace('\'', "''"),
name = perm.name.replace('\'', "''"),
resource = resource.replace('\'', "''"),
action = action.replace('\'', "''"),
desc = description_sql,
now = now.to_rfc3339(),
operator_id = operator_id,
);
db.execute(sea_orm::Statement::from_string( db.execute(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres, sea_orm::DatabaseBackend::Postgres,
sql, sql,
vec![
sea_orm::Value::from(tenant_id),
sea_orm::Value::from(full_code.clone()),
sea_orm::Value::from(perm.name.clone()),
sea_orm::Value::from(resource),
sea_orm::Value::from(action),
sea_orm::Value::from(description),
sea_orm::Value::from(now.clone()),
sea_orm::Value::from(operator_id),
],
)) ))
.await .await
.map_err(|e| { .map_err(|e| {
@@ -645,28 +670,28 @@ async fn unregister_plugin_permissions(
plugin_manifest_id: &str, plugin_manifest_id: &str,
) -> AppResult<()> { ) -> AppResult<()> {
let prefix = format!("{}.%", plugin_manifest_id); let prefix = format!("{}.%", plugin_manifest_id);
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now();
// 先软删除 role_permissions 中的关联 // 先软删除 role_permissions 中的关联
let rp_sql = format!( let rp_sql = r#"
r#"
UPDATE role_permissions UPDATE role_permissions
SET deleted_at = '{now}', updated_at = '{now}' SET deleted_at = $1, updated_at = $1
WHERE permission_id IN ( WHERE permission_id IN (
SELECT id FROM permissions SELECT id FROM permissions
WHERE tenant_id = '{tenant_id}' WHERE tenant_id = $2
AND code LIKE '{prefix}' AND code LIKE $3
AND deleted_at IS NULL AND deleted_at IS NULL
) )
AND deleted_at IS NULL AND deleted_at IS NULL
"#, "#;
now = now, db.execute(sea_orm::Statement::from_sql_and_values(
tenant_id = tenant_id,
prefix = prefix.replace('\'', "''"),
);
db.execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres, sea_orm::DatabaseBackend::Postgres,
rp_sql, rp_sql,
vec![
sea_orm::Value::from(now.clone()),
sea_orm::Value::from(tenant_id),
sea_orm::Value::from(prefix.clone()),
],
)) ))
.await .await
.map_err(|e| { .map_err(|e| {
@@ -679,21 +704,21 @@ async fn unregister_plugin_permissions(
})?; })?;
// 再软删除 permissions // 再软删除 permissions
let perm_sql = format!( let perm_sql = r#"
r#"
UPDATE permissions UPDATE permissions
SET deleted_at = '{now}', updated_at = '{now}' SET deleted_at = $1, updated_at = $1
WHERE tenant_id = '{tenant_id}' WHERE tenant_id = $2
AND code LIKE '{prefix}' AND code LIKE $3
AND deleted_at IS NULL AND deleted_at IS NULL
"#, "#;
now = now, db.execute(sea_orm::Statement::from_sql_and_values(
tenant_id = tenant_id,
prefix = prefix.replace('\'', "''"),
);
db.execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres, sea_orm::DatabaseBackend::Postgres,
perm_sql, perm_sql,
vec![
sea_orm::Value::from(now),
sea_orm::Value::from(tenant_id),
sea_orm::Value::from(prefix),
],
)) ))
.await .await
.map_err(|e| { .map_err(|e| {