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:
@@ -54,7 +54,7 @@ display_name = "客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "code"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "客户编码"
|
||||
unique = true
|
||||
@@ -62,14 +62,14 @@ display_name = "客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "name"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "客户名称"
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_type"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "客户类型"
|
||||
ui_widget = "select"
|
||||
@@ -81,19 +81,19 @@ display_name = "客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "industry"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "行业"
|
||||
filterable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "region"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "地区"
|
||||
filterable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "source"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "来源"
|
||||
ui_widget = "select"
|
||||
options = [
|
||||
@@ -106,7 +106,7 @@ display_name = "客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "level"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "等级"
|
||||
ui_widget = "select"
|
||||
filterable = true
|
||||
@@ -119,7 +119,7 @@ display_name = "客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "status"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "状态"
|
||||
ui_widget = "select"
|
||||
@@ -132,34 +132,34 @@ display_name = "客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "credit_code"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "统一社会信用代码"
|
||||
visible_when = "customer_type == 'enterprise'"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "id_number"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "身份证号"
|
||||
visible_when = "customer_type == 'personal'"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "parent_id"
|
||||
field_type = "Uuid"
|
||||
field_type = "uuid"
|
||||
display_name = "上级客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "website"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "网站"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "address"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "地址"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "remark"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "备注"
|
||||
ui_widget = "textarea"
|
||||
|
||||
@@ -169,50 +169,50 @@ display_name = "联系人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "Uuid"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "所属客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "name"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "姓名"
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "position"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "职务"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "department"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "部门"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "phone"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "手机号"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "email"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "邮箱"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "wechat"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "微信号"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "is_primary"
|
||||
field_type = "Boolean"
|
||||
field_type = "boolean"
|
||||
display_name = "主联系人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "remark"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "备注"
|
||||
|
||||
[[schema.entities]]
|
||||
@@ -221,18 +221,18 @@ display_name = "沟通记录"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "Uuid"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "关联客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "contact_id"
|
||||
field_type = "Uuid"
|
||||
field_type = "uuid"
|
||||
display_name = "关联联系人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "type"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "类型"
|
||||
ui_widget = "select"
|
||||
@@ -247,28 +247,28 @@ display_name = "沟通记录"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "subject"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "主题"
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "content"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "内容"
|
||||
ui_widget = "textarea"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "occurred_at"
|
||||
field_type = "DateTime"
|
||||
field_type = "date_time"
|
||||
required = true
|
||||
display_name = "沟通时间"
|
||||
sortable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "next_follow_up"
|
||||
field_type = "Date"
|
||||
field_type = "date"
|
||||
display_name = "下次跟进日期"
|
||||
|
||||
[[schema.entities]]
|
||||
@@ -277,20 +277,20 @@ display_name = "客户标签"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "Uuid"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "关联客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "tag_name"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "标签名称"
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "tag_category"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "标签分类"
|
||||
ui_widget = "select"
|
||||
options = [
|
||||
@@ -306,19 +306,19 @@ display_name = "客户关系"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "from_customer_id"
|
||||
field_type = "Uuid"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "源客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "to_customer_id"
|
||||
field_type = "Uuid"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "目标客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "relationship_type"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "关系类型"
|
||||
ui_widget = "select"
|
||||
@@ -333,7 +333,7 @@ display_name = "客户关系"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "description"
|
||||
field_type = "String"
|
||||
field_type = "string"
|
||||
display_name = "关系描述"
|
||||
|
||||
# ── 页面声明 ──
|
||||
|
||||
@@ -6,12 +6,28 @@ use erp_core::events::EventBus;
|
||||
|
||||
use crate::data_dto::PluginDataResp;
|
||||
use crate::dynamic_table::DynamicTableManager;
|
||||
use crate::entity::plugin;
|
||||
use crate::entity::plugin_entity;
|
||||
use crate::error::PluginError;
|
||||
use crate::manifest::PluginField;
|
||||
|
||||
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 {
|
||||
/// 创建插件数据
|
||||
pub async fn create(
|
||||
@@ -23,13 +39,12 @@ impl PluginDataService {
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
// 数据校验
|
||||
let fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let fields = info.fields()?;
|
||||
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);
|
||||
DynamicTableManager::build_insert_sql(&info.table_name, tenant_id, operator_id, &data);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct InsertResult {
|
||||
@@ -71,10 +86,10 @@ impl PluginDataService {
|
||||
sort_by: Option<String>,
|
||||
sort_order: Option<String>,
|
||||
) -> 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 字段列表
|
||||
let entity_fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let entity_fields = info.fields()?;
|
||||
let search_tuple = {
|
||||
let searchable: Vec<&str> = entity_fields
|
||||
.iter()
|
||||
@@ -87,9 +102,9 @@ impl PluginDataService {
|
||||
}
|
||||
};
|
||||
|
||||
// Count(使用基础条件,不含 filter/search 以保持计数一致性)
|
||||
// Count
|
||||
let (count_sql, count_values) =
|
||||
DynamicTableManager::build_count_sql(&table_name, tenant_id);
|
||||
DynamicTableManager::build_count_sql(&info.table_name, tenant_id);
|
||||
#[derive(FromQueryResult)]
|
||||
struct CountResult {
|
||||
count: i64,
|
||||
@@ -104,10 +119,10 @@ impl PluginDataService {
|
||||
.map(|r| r.count as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Query(带过滤/搜索/排序)
|
||||
// Query
|
||||
let offset = page.saturating_sub(1) * page_size;
|
||||
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
|
||||
&table_name,
|
||||
&info.table_name,
|
||||
tenant_id,
|
||||
page_size,
|
||||
offset,
|
||||
@@ -157,8 +172,8 @@ impl PluginDataService {
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_get_by_id_sql(&table_name, id, tenant_id);
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_get_by_id_sql(&info.table_name, id, tenant_id);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct DataRow {
|
||||
@@ -199,13 +214,12 @@ impl PluginDataService {
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
// 数据校验
|
||||
let fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let fields = info.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(
|
||||
&table_name,
|
||||
&info.table_name,
|
||||
id,
|
||||
tenant_id,
|
||||
operator_id,
|
||||
@@ -249,8 +263,8 @@ impl PluginDataService {
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<()> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_delete_sql(&table_name, id, tenant_id);
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_delete_sql(&info.table_name, id, tenant_id);
|
||||
|
||||
db.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
@@ -271,10 +285,9 @@ impl PluginDataService {
|
||||
filter: Option<serde_json::Value>,
|
||||
search: Option<String>,
|
||||
) -> 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 = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let entity_fields = info.fields()?;
|
||||
let search_tuple = {
|
||||
let searchable: Vec<&str> = entity_fields
|
||||
.iter()
|
||||
@@ -288,7 +301,7 @@ impl PluginDataService {
|
||||
};
|
||||
|
||||
let (sql, values) = DynamicTableManager::build_filtered_count_sql(
|
||||
&table_name,
|
||||
&info.table_name,
|
||||
tenant_id,
|
||||
filter,
|
||||
search_tuple,
|
||||
@@ -323,10 +336,10 @@ impl PluginDataService {
|
||||
group_by_field: &str,
|
||||
filter: Option<serde_json::Value>,
|
||||
) -> 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(
|
||||
&table_name,
|
||||
&info.table_name,
|
||||
tenant_id,
|
||||
group_by_field,
|
||||
filter,
|
||||
@@ -356,13 +369,34 @@ impl PluginDataService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 plugin_entities 表解析 table_name(带租户隔离)
|
||||
async fn resolve_table_name(
|
||||
/// 从 plugins 表解析 manifest metadata.id(如 "erp-crm")
|
||||
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,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<String> {
|
||||
) -> AppResult<EntityInfo> {
|
||||
let entity = plugin_entity::Entity::find()
|
||||
.filter(plugin_entity::Column::PluginId.eq(plugin_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))
|
||||
})?;
|
||||
|
||||
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)
|
||||
Ok(EntityInfo {
|
||||
table_name: entity.table_name,
|
||||
schema_json: entity.schema_json,
|
||||
})
|
||||
}
|
||||
|
||||
/// 校验数据:检查 required 字段
|
||||
|
||||
@@ -45,28 +45,25 @@ impl DynamicTableManager {
|
||||
\"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()))?;
|
||||
db.execute_unprepared(&create_sql).await
|
||||
.map_err(|e| {
|
||||
tracing::error!(sql = %create_sql, error = %e, "CREATE TABLE failed");
|
||||
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
|
||||
db.execute_unprepared(&tenant_idx_sql).await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
// 为字段创建索引(使用参数化方式避免 SQL 注入)
|
||||
let mut idx_counter = 0usize;
|
||||
for field in &entity.fields {
|
||||
if field.unique || field.required {
|
||||
idx_counter += 1;
|
||||
let sanitized_field = sanitize_identifier(&field.name);
|
||||
let idx_name = format!(
|
||||
"idx_{}_{}_{}",
|
||||
@@ -77,37 +74,42 @@ impl DynamicTableManager {
|
||||
// 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"
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL",
|
||||
idx_name, table_name, sanitized_field
|
||||
)
|
||||
} else {
|
||||
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(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
idx_sql,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
tracing::info!(step = idx_counter, field = %field.name, unique = field.unique, sql = %idx_sql, "Creating field index");
|
||||
match db.execute_unprepared(&idx_sql).await {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
tracing::warn!(step = idx_counter, field = %field.name, sql = %idx_sql, error = %e, "Index creation skipped (non-fatal)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 为 searchable 字段创建 B-tree 索引以加速 ILIKE 前缀查询
|
||||
for field in &entity.fields {
|
||||
if field.searchable == Some(true) {
|
||||
idx_counter += 1;
|
||||
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!(step = idx_counter, field = %field.name, sql = %idx_sql, "Creating search index");
|
||||
match db.execute_unprepared(&idx_sql).await {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
tracing::warn!(step = idx_counter, field = %field.name, sql = %idx_sql, error = %e, "Search index creation skipped (non-fatal)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +174,7 @@ impl DynamicTableManager {
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"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",
|
||||
table_name
|
||||
);
|
||||
@@ -226,7 +228,7 @@ impl DynamicTableManager {
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"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 \
|
||||
RETURNING id, data, created_at, updated_at, version",
|
||||
table_name
|
||||
|
||||
@@ -11,17 +11,17 @@ use crate::data_dto::{
|
||||
AggregateItem, AggregateQueryParams, CountQueryParams, CreatePluginDataReq,
|
||||
PluginDataListParams, PluginDataResp, UpdatePluginDataReq,
|
||||
};
|
||||
use crate::data_service::PluginDataService;
|
||||
use crate::data_service::{PluginDataService, resolve_manifest_id};
|
||||
use crate::state::PluginState;
|
||||
|
||||
/// 计算插件数据操作所需的权限码
|
||||
/// 格式:{plugin_id}.{entity}.{action},如 crm.customer.list
|
||||
fn compute_permission_code(plugin_id: &str, entity_name: &str, action: &str) -> String {
|
||||
/// 格式:{manifest_id}.{entity}.{action},如 erp-crm.customer.list
|
||||
fn compute_permission_code(manifest_id: &str, entity_name: &str, action: &str) -> String {
|
||||
let action_suffix = match action {
|
||||
"list" | "get" => "list",
|
||||
_ => "manage",
|
||||
};
|
||||
format!("{}.{}.{}", plugin_id, entity_name, action_suffix)
|
||||
format!("{}.{}.{}", manifest_id, entity_name, action_suffix)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -45,8 +45,8 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
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() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
@@ -104,7 +104,8 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
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() {
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
}
|
||||
@@ -142,7 +143,8 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
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() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
@@ -174,7 +176,8 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
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() {
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
}
|
||||
@@ -214,7 +217,8 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
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() {
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
}
|
||||
@@ -253,7 +257,8 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
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() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
@@ -298,7 +303,8 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
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() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
|
||||
@@ -199,7 +199,11 @@ where
|
||||
&state.db,
|
||||
&state.engine,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Install failed");
|
||||
e
|
||||
})?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
|
||||
@@ -96,11 +96,16 @@ impl PluginService {
|
||||
// 创建动态表 + 注册 entity 记录
|
||||
let mut entity_resps = Vec::new();
|
||||
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);
|
||||
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 记录
|
||||
let entity_id = Uuid::now_v7();
|
||||
@@ -143,6 +148,7 @@ impl PluginService {
|
||||
}
|
||||
|
||||
// 注册插件声明的权限到 permissions 表
|
||||
tracing::info!("Registering plugin permissions");
|
||||
if let Some(perms) = &manifest.permissions {
|
||||
register_plugin_permissions(
|
||||
db,
|
||||
@@ -152,10 +158,15 @@ impl PluginService {
|
||||
perms,
|
||||
&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
|
||||
.load(
|
||||
&manifest.metadata.id,
|
||||
@@ -454,7 +465,22 @@ impl PluginService {
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.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,
|
||||
) -> AppResult<()> {
|
||||
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 mut active: plugin::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(now));
|
||||
@@ -585,34 +611,33 @@ async fn register_plugin_permissions(
|
||||
) -> AppResult<()> {
|
||||
for perm in perms {
|
||||
let full_code = format!("{}.{}", plugin_manifest_id, perm.code);
|
||||
// resource 使用插件 manifest id,action 使用权限的 code 字段
|
||||
let resource = plugin_manifest_id.to_string();
|
||||
let action = perm.code.clone();
|
||||
let description_sql = if perm.description.is_empty() {
|
||||
"NULL".to_string()
|
||||
let description: Option<String> = if perm.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
format!("'{}'", perm.description.replace('\'', "''"))
|
||||
Some(perm.description.clone())
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
r#"
|
||||
let sql = r#"
|
||||
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
|
||||
"#,
|
||||
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,
|
||||
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
|
||||
.map_err(|e| {
|
||||
@@ -645,28 +670,28 @@ async fn unregister_plugin_permissions(
|
||||
plugin_manifest_id: &str,
|
||||
) -> AppResult<()> {
|
||||
let prefix = format!("{}.%", plugin_manifest_id);
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// 先软删除 role_permissions 中的关联
|
||||
let rp_sql = format!(
|
||||
r#"
|
||||
let rp_sql = r#"
|
||||
UPDATE role_permissions
|
||||
SET deleted_at = '{now}', updated_at = '{now}'
|
||||
SET deleted_at = $1, updated_at = $1
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions
|
||||
WHERE tenant_id = '{tenant_id}'
|
||||
AND code LIKE '{prefix}'
|
||||
WHERE tenant_id = $2
|
||||
AND code LIKE $3
|
||||
AND deleted_at IS NULL
|
||||
)
|
||||
AND deleted_at IS NULL
|
||||
"#,
|
||||
now = now,
|
||||
tenant_id = tenant_id,
|
||||
prefix = prefix.replace('\'', "''"),
|
||||
);
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
"#;
|
||||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
rp_sql,
|
||||
vec![
|
||||
sea_orm::Value::from(now.clone()),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::from(prefix.clone()),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -679,21 +704,21 @@ async fn unregister_plugin_permissions(
|
||||
})?;
|
||||
|
||||
// 再软删除 permissions
|
||||
let perm_sql = format!(
|
||||
r#"
|
||||
let perm_sql = r#"
|
||||
UPDATE permissions
|
||||
SET deleted_at = '{now}', updated_at = '{now}'
|
||||
WHERE tenant_id = '{tenant_id}'
|
||||
AND code LIKE '{prefix}'
|
||||
SET deleted_at = $1, updated_at = $1
|
||||
WHERE tenant_id = $2
|
||||
AND code LIKE $3
|
||||
AND deleted_at IS NULL
|
||||
"#,
|
||||
now = now,
|
||||
tenant_id = tenant_id,
|
||||
prefix = prefix.replace('\'', "''"),
|
||||
);
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
"#;
|
||||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
perm_sql,
|
||||
vec![
|
||||
sea_orm::Value::from(now),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::from(prefix),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
|
||||
Reference in New Issue
Block a user