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]]
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 = "关系描述"
# ── 页面声明 ──

View File

@@ -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 字段

View File

@@ -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

View File

@@ -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")?;
}

View File

@@ -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)))
}

View File

@@ -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 idaction 使用权限的 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| {