feat(plugin): 集成 WASM 插件系统到主服务并修复链路问题
- 新增 erp-plugin crate:插件管理、WASM 运行时、动态表、数据 CRUD - 新增前端插件管理页面(PluginAdmin/PluginCRUDPage)和 API 层 - 新增插件数据迁移(plugins/plugin_entities/plugin_event_subscriptions) - 新增权限补充迁移(为已有租户补充 plugin.admin/plugin.list 权限) - 修复 PluginAdmin 页面 InstallOutlined 图标不存在的崩溃问题 - 修复 settings 唯一索引迁移顺序错误(先去重再建索引) - 更新 wiki 和 CLAUDE.md 反映插件系统集成状态 - 新增 dev.ps1 一键启动脚本
This commit is contained in:
250
crates/erp-plugin/src/dynamic_table.rs
Normal file
250
crates/erp-plugin/src/dynamic_table.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, FromQueryResult, Statement, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{PluginError, PluginResult};
|
||||
use crate::manifest::PluginEntity;
|
||||
|
||||
/// 消毒标识符:只保留 ASCII 字母、数字、下划线,防止 SQL 注入
|
||||
fn sanitize_identifier(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 动态表管理器 — 处理插件动态创建/删除的数据库表
|
||||
pub struct DynamicTableManager;
|
||||
|
||||
impl DynamicTableManager {
|
||||
/// 生成动态表名: `plugin_{sanitized_id}_{sanitized_entity}`
|
||||
pub fn table_name(plugin_id: &str, entity_name: &str) -> String {
|
||||
let sanitized_id = sanitize_identifier(plugin_id);
|
||||
let sanitized_entity = sanitize_identifier(entity_name);
|
||||
format!("plugin_{}_{}", sanitized_id, sanitized_entity)
|
||||
}
|
||||
|
||||
/// 创建动态表
|
||||
pub async fn create_table(
|
||||
db: &DatabaseConnection,
|
||||
plugin_id: &str,
|
||||
entity: &PluginEntity,
|
||||
) -> PluginResult<()> {
|
||||
let table_name = Self::table_name(plugin_id, &entity.name);
|
||||
|
||||
// 创建表
|
||||
let create_sql = format!(
|
||||
"CREATE TABLE IF NOT EXISTS \"{table_name}\" (\
|
||||
\"id\" UUID PRIMARY KEY, \
|
||||
\"tenant_id\" UUID NOT NULL, \
|
||||
\"data\" JSONB NOT NULL DEFAULT '{{}}', \
|
||||
\"created_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
|
||||
\"updated_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
|
||||
\"created_by\" UUID, \
|
||||
\"updated_by\" UUID, \
|
||||
\"deleted_at\" TIMESTAMPTZ, \
|
||||
\"version\" INT NOT NULL DEFAULT 1)"
|
||||
);
|
||||
|
||||
db.execute(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
create_sql,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
// 创建租户索引
|
||||
let tenant_idx_sql = format!(
|
||||
"CREATE INDEX IF NOT EXISTS \"idx_{t}_tenant\" ON \"{table_name}\" (\"tenant_id\") WHERE \"deleted_at\" IS NULL",
|
||||
t = sanitize_identifier(&table_name)
|
||||
);
|
||||
db.execute(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
tenant_idx_sql,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
// 为字段创建索引(使用参数化方式避免 SQL 注入)
|
||||
for field in &entity.fields {
|
||||
if field.unique || field.required {
|
||||
let sanitized_field = sanitize_identifier(&field.name);
|
||||
let idx_name = format!(
|
||||
"idx_{}_{}_{}",
|
||||
sanitize_identifier(&table_name),
|
||||
sanitized_field,
|
||||
if field.unique { "uniq" } else { "idx" }
|
||||
);
|
||||
let idx_sql = format!(
|
||||
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" (\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
|
||||
);
|
||||
db.execute(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
idx_sql,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(table = %table_name, "Dynamic table created");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除动态表
|
||||
pub async fn drop_table(
|
||||
db: &DatabaseConnection,
|
||||
plugin_id: &str,
|
||||
entity_name: &str,
|
||||
) -> PluginResult<()> {
|
||||
let table_name = Self::table_name(plugin_id, entity_name);
|
||||
let sql = format!("DROP TABLE IF EXISTS \"{}\"", table_name);
|
||||
db.execute(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
tracing::info!(table = %table_name, "Dynamic table dropped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查表是否存在
|
||||
pub async fn table_exists(db: &DatabaseConnection, table_name: &str) -> PluginResult<bool> {
|
||||
#[derive(FromQueryResult)]
|
||||
struct ExistsResult {
|
||||
exists: bool,
|
||||
}
|
||||
let result = ExistsResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)",
|
||||
[table_name.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
Ok(result.map(|r| r.exists).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// 构建 INSERT SQL
|
||||
pub fn build_insert_sql(
|
||||
table_name: &str,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
data: &serde_json::Value,
|
||||
) -> (String, Vec<Value>) {
|
||||
let id = Uuid::now_v7();
|
||||
Self::build_insert_sql_with_id(table_name, id, tenant_id, user_id, data)
|
||||
}
|
||||
|
||||
/// 构建 INSERT SQL(指定 ID)
|
||||
pub fn build_insert_sql_with_id(
|
||||
table_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
data: &serde_json::Value,
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"INSERT INTO \"{}\" (id, tenant_id, data, created_by, updated_by, version) \
|
||||
VALUES ($1, $2, $3, $4, $5, 1) \
|
||||
RETURNING id, tenant_id, data, created_at, updated_at, version",
|
||||
table_name
|
||||
);
|
||||
let values = vec![
|
||||
id.into(),
|
||||
tenant_id.into(),
|
||||
serde_json::to_string(data).unwrap_or_default().into(),
|
||||
user_id.into(),
|
||||
user_id.into(),
|
||||
];
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建 SELECT SQL
|
||||
pub fn build_query_sql(
|
||||
table_name: &str,
|
||||
tenant_id: Uuid,
|
||||
limit: u64,
|
||||
offset: u64,
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"SELECT id, data, created_at, updated_at, version \
|
||||
FROM \"{}\" \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
ORDER BY created_at DESC \
|
||||
LIMIT $2 OFFSET $3",
|
||||
table_name
|
||||
);
|
||||
let values = vec![tenant_id.into(), (limit as i64).into(), (offset as i64).into()];
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建 COUNT SQL
|
||||
pub fn build_count_sql(table_name: &str, tenant_id: Uuid) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) as count FROM \"{}\" WHERE tenant_id = $1 AND deleted_at IS NULL",
|
||||
table_name
|
||||
);
|
||||
let values = vec![tenant_id.into()];
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建 UPDATE SQL(含乐观锁)
|
||||
pub fn build_update_sql(
|
||||
table_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
data: &serde_json::Value,
|
||||
version: i32,
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"UPDATE \"{}\" \
|
||||
SET data = $1, updated_at = NOW(), updated_by = $2, version = version + 1 \
|
||||
WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \
|
||||
RETURNING id, data, created_at, updated_at, version",
|
||||
table_name
|
||||
);
|
||||
let values = vec![
|
||||
serde_json::to_string(data).unwrap_or_default().into(),
|
||||
user_id.into(),
|
||||
id.into(),
|
||||
tenant_id.into(),
|
||||
version.into(),
|
||||
];
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建 DELETE SQL(软删除)
|
||||
pub fn build_delete_sql(
|
||||
table_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"UPDATE \"{}\" \
|
||||
SET deleted_at = NOW(), updated_at = NOW() \
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||||
table_name
|
||||
);
|
||||
let values = vec![id.into(), tenant_id.into()];
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建单条查询 SQL
|
||||
pub fn build_get_by_id_sql(
|
||||
table_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"SELECT id, data, created_at, updated_at, version \
|
||||
FROM \"{}\" \
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||||
table_name
|
||||
);
|
||||
let values = vec![id.into(), tenant_id.into()];
|
||||
(sql, values)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user