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:
iven
2026-04-15 23:32:02 +08:00
parent 7e8fabb095
commit ff352a4c24
46 changed files with 6723 additions and 19 deletions

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