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:
33
crates/erp-plugin/src/data_dto.rs
Normal file
33
crates/erp-plugin/src/data_dto.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 插件数据记录响应
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PluginDataResp {
|
||||
pub id: String,
|
||||
pub data: serde_json::Value,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
/// 创建插件数据请求
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CreatePluginDataReq {
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 更新插件数据请求
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdatePluginDataReq {
|
||||
pub data: serde_json::Value,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 插件数据列表查询参数
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
||||
pub struct PluginDataListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
250
crates/erp-plugin/src/data_service.rs
Normal file
250
crates/erp-plugin/src/data_service.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, Statement};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::data_dto::PluginDataResp;
|
||||
use crate::dynamic_table::DynamicTableManager;
|
||||
use crate::entity::plugin_entity;
|
||||
use crate::error::PluginError;
|
||||
|
||||
pub struct PluginDataService;
|
||||
|
||||
impl PluginDataService {
|
||||
/// 创建插件数据
|
||||
pub async fn create(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
data: serde_json::Value,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
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);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct InsertResult {
|
||||
id: Uuid,
|
||||
data: serde_json::Value,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
let result = InsertResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| PluginError::DatabaseError("INSERT 未返回结果".to_string()))?;
|
||||
|
||||
Ok(PluginDataResp {
|
||||
id: result.id.to_string(),
|
||||
data: result.data,
|
||||
created_at: Some(result.created_at),
|
||||
updated_at: Some(result.updated_at),
|
||||
version: Some(result.version),
|
||||
})
|
||||
}
|
||||
|
||||
/// 列表查询
|
||||
pub async fn list(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<(Vec<PluginDataResp>, u64)> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
|
||||
// Count
|
||||
let (count_sql, count_values) = DynamicTableManager::build_count_sql(&table_name, tenant_id);
|
||||
#[derive(FromQueryResult)]
|
||||
struct CountResult {
|
||||
count: i64,
|
||||
}
|
||||
let total = CountResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
count_sql,
|
||||
count_values,
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.map(|r| r.count as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Query
|
||||
let offset = (page.saturating_sub(1)) * page_size;
|
||||
let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_id, page_size, offset);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct DataRow {
|
||||
id: Uuid,
|
||||
data: serde_json::Value,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
let rows = DataRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| PluginDataResp {
|
||||
id: r.id.to_string(),
|
||||
data: r.data,
|
||||
created_at: Some(r.created_at),
|
||||
updated_at: Some(r.updated_at),
|
||||
version: Some(r.version),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
/// 按 ID 获取
|
||||
pub async fn get_by_id(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
id: Uuid,
|
||||
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);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct DataRow {
|
||||
id: Uuid,
|
||||
data: serde_json::Value,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
let row = DataRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| erp_core::error::AppError::NotFound("记录不存在".to_string()))?;
|
||||
|
||||
Ok(PluginDataResp {
|
||||
id: row.id.to_string(),
|
||||
data: row.data,
|
||||
created_at: Some(row.created_at),
|
||||
updated_at: Some(row.updated_at),
|
||||
version: Some(row.version),
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新
|
||||
pub async fn update(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
data: serde_json::Value,
|
||||
expected_version: i32,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_update_sql(
|
||||
&table_name,
|
||||
id,
|
||||
tenant_id,
|
||||
operator_id,
|
||||
&data,
|
||||
expected_version,
|
||||
);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct UpdateResult {
|
||||
id: Uuid,
|
||||
data: serde_json::Value,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
let result = UpdateResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| erp_core::error::AppError::VersionMismatch)?;
|
||||
|
||||
Ok(PluginDataResp {
|
||||
id: result.id.to_string(),
|
||||
data: result.data,
|
||||
created_at: Some(result.created_at),
|
||||
updated_at: Some(result.updated_at),
|
||||
version: Some(result.version),
|
||||
})
|
||||
}
|
||||
|
||||
/// 删除(软删除)
|
||||
pub async fn delete(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
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);
|
||||
|
||||
db.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 plugin_entities 表解析 table_name(带租户隔离)
|
||||
async fn resolve_table_name(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<String> {
|
||||
let entity = 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(|| {
|
||||
erp_core::error::AppError::NotFound(format!(
|
||||
"插件实体 {}/{} 不存在",
|
||||
plugin_id, entity_name
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(entity.table_name)
|
||||
}
|
||||
65
crates/erp-plugin/src/dto.rs
Normal file
65
crates/erp-plugin/src/dto.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 插件信息响应
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PluginResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
pub status: String,
|
||||
pub config: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub installed_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_at: Option<DateTime<Utc>>,
|
||||
pub entities: Vec<PluginEntityResp>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permissions: Option<Vec<PluginPermissionResp>>,
|
||||
pub record_version: i32,
|
||||
}
|
||||
|
||||
/// 插件实体信息
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PluginEntityResp {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub table_name: String,
|
||||
}
|
||||
|
||||
/// 插件权限信息
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PluginPermissionResp {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// 插件健康检查响应
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PluginHealthResp {
|
||||
pub plugin_id: Uuid,
|
||||
pub status: String,
|
||||
pub details: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 更新插件配置请求
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdatePluginConfigReq {
|
||||
pub config: serde_json::Value,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 插件列表查询参数
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
||||
pub struct PluginListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub status: Option<String>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
664
crates/erp-plugin/src/engine.rs
Normal file
664
crates/erp-plugin/src/engine.rs
Normal file
@@ -0,0 +1,664 @@
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, TransactionTrait};
|
||||
use serde_json::json;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
use wasmtime::component::{Component, HasSelf, Linker};
|
||||
use wasmtime::{Config, Engine, Store};
|
||||
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::PluginWorld;
|
||||
use crate::dynamic_table::DynamicTableManager;
|
||||
use crate::error::{PluginError, PluginResult};
|
||||
use crate::host::{HostState, PendingOp};
|
||||
use crate::manifest::PluginManifest;
|
||||
|
||||
/// 插件引擎配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginEngineConfig {
|
||||
/// 默认 Fuel 限制
|
||||
pub default_fuel: u64,
|
||||
/// 执行超时(秒)
|
||||
pub execution_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for PluginEngineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_fuel: 10_000_000,
|
||||
execution_timeout_secs: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 插件运行状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginStatus {
|
||||
/// 已加载到内存
|
||||
Loaded,
|
||||
/// 已初始化(init() 已调用)
|
||||
Initialized,
|
||||
/// 运行中(事件监听已启动)
|
||||
Running,
|
||||
/// 错误状态
|
||||
Error(String),
|
||||
/// 已禁用
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// 已加载的插件实例
|
||||
pub struct LoadedPlugin {
|
||||
pub id: String,
|
||||
pub manifest: PluginManifest,
|
||||
pub component: Component,
|
||||
pub linker: Linker<HostState>,
|
||||
pub status: RwLock<PluginStatus>,
|
||||
pub event_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
/// WASM 执行上下文 — 传递真实的租户和用户信息
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecutionContext {
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
/// 插件引擎 — 管理所有已加载插件的 WASM 运行时
|
||||
#[derive(Clone)]
|
||||
pub struct PluginEngine {
|
||||
engine: Arc<Engine>,
|
||||
db: DatabaseConnection,
|
||||
event_bus: EventBus,
|
||||
plugins: Arc<DashMap<String, Arc<LoadedPlugin>>>,
|
||||
config: PluginEngineConfig,
|
||||
}
|
||||
|
||||
impl PluginEngine {
|
||||
/// 创建新的插件引擎
|
||||
pub fn new(
|
||||
db: DatabaseConnection,
|
||||
event_bus: EventBus,
|
||||
config: PluginEngineConfig,
|
||||
) -> PluginResult<Self> {
|
||||
let mut wasm_config = Config::new();
|
||||
wasm_config.wasm_component_model(true);
|
||||
wasm_config.consume_fuel(true);
|
||||
let engine = Engine::new(&wasm_config)
|
||||
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
engine: Arc::new(engine),
|
||||
db,
|
||||
event_bus,
|
||||
plugins: Arc::new(DashMap::new()),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// 加载插件到内存(不初始化)
|
||||
pub async fn load(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
wasm_bytes: &[u8],
|
||||
manifest: PluginManifest,
|
||||
) -> PluginResult<()> {
|
||||
if self.plugins.contains_key(plugin_id) {
|
||||
return Err(PluginError::AlreadyExists(plugin_id.to_string()));
|
||||
}
|
||||
|
||||
let component = Component::from_binary(&self.engine, wasm_bytes)
|
||||
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
|
||||
|
||||
let mut linker = Linker::new(&self.engine);
|
||||
// 注册 Host API 到 Linker
|
||||
PluginWorld::add_to_linker::<_, HasSelf<HostState>>(&mut linker, |state| state)
|
||||
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
|
||||
|
||||
let loaded = Arc::new(LoadedPlugin {
|
||||
id: plugin_id.to_string(),
|
||||
manifest,
|
||||
component,
|
||||
linker,
|
||||
status: RwLock::new(PluginStatus::Loaded),
|
||||
event_handles: RwLock::new(vec![]),
|
||||
});
|
||||
|
||||
self.plugins.insert(plugin_id.to_string(), loaded);
|
||||
tracing::info!(plugin_id, "Plugin loaded into memory");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化插件(调用 init())
|
||||
pub async fn initialize(&self, plugin_id: &str) -> PluginResult<()> {
|
||||
let loaded = self.get_loaded(plugin_id)?;
|
||||
|
||||
// 检查状态
|
||||
{
|
||||
let status = loaded.status.read().await;
|
||||
if *status != PluginStatus::Loaded {
|
||||
return Err(PluginError::InvalidState {
|
||||
expected: "Loaded".to_string(),
|
||||
actual: format!("{:?}", *status),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let ctx = ExecutionContext {
|
||||
tenant_id: Uuid::nil(),
|
||||
user_id: Uuid::nil(),
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let result = self
|
||||
.execute_wasm(plugin_id, &ctx, |store, instance| {
|
||||
instance.erp_plugin_plugin_api().call_init(store)
|
||||
.map_err(|e| PluginError::ExecutionError(e.to_string()))?
|
||||
.map_err(|e| PluginError::ExecutionError(e))?;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
*loaded.status.write().await = PluginStatus::Initialized;
|
||||
tracing::info!(plugin_id, "Plugin initialized");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
*loaded.status.write().await = PluginStatus::Error(e.to_string());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动事件监听
|
||||
pub async fn start_event_listener(&self, plugin_id: &str) -> PluginResult<()> {
|
||||
let loaded = self.get_loaded(plugin_id)?;
|
||||
|
||||
// 检查状态
|
||||
{
|
||||
let status = loaded.status.read().await;
|
||||
if *status != PluginStatus::Initialized {
|
||||
return Err(PluginError::InvalidState {
|
||||
expected: "Initialized".to_string(),
|
||||
actual: format!("{:?}", *status),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let events_config = &loaded.manifest.events;
|
||||
if let Some(events) = events_config {
|
||||
for pattern in &events.subscribe {
|
||||
let (mut rx, sub_handle) = self.event_bus.subscribe_filtered(pattern.clone());
|
||||
let pid = plugin_id.to_string();
|
||||
let engine = self.clone();
|
||||
|
||||
let join_handle = tokio::spawn(async move {
|
||||
// sub_handle 保存在此 task 中,task 结束时自动 drop 触发优雅取消
|
||||
let _sub_guard = sub_handle;
|
||||
while let Some(event) = rx.recv().await {
|
||||
if let Err(e) = engine
|
||||
.handle_event_inner(
|
||||
&pid,
|
||||
&event.event_type,
|
||||
&event.payload,
|
||||
event.tenant_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
plugin_id = %pid,
|
||||
error = %e,
|
||||
"Plugin event handler failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loaded.event_handles.write().await.push(join_handle);
|
||||
}
|
||||
}
|
||||
|
||||
*loaded.status.write().await = PluginStatus::Running;
|
||||
tracing::info!(plugin_id, "Plugin event listener started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理单个事件
|
||||
pub async fn handle_event(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
event_type: &str,
|
||||
payload: &serde_json::Value,
|
||||
tenant_id: Uuid,
|
||||
) -> PluginResult<()> {
|
||||
self.handle_event_inner(plugin_id, event_type, payload, tenant_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_event_inner(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
event_type: &str,
|
||||
payload: &serde_json::Value,
|
||||
tenant_id: Uuid,
|
||||
) -> PluginResult<()> {
|
||||
let payload_bytes = serde_json::to_vec(payload).unwrap_or_default();
|
||||
let event_type = event_type.to_owned();
|
||||
|
||||
let ctx = ExecutionContext {
|
||||
tenant_id,
|
||||
user_id: Uuid::nil(),
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
self.execute_wasm(plugin_id, &ctx, move |store, instance| {
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_handle_event(store, &event_type, &payload_bytes)
|
||||
.map_err(|e| PluginError::ExecutionError(e.to_string()))?
|
||||
.map_err(|e| PluginError::ExecutionError(e))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 租户创建时调用插件的 on_tenant_created
|
||||
pub async fn on_tenant_created(&self, plugin_id: &str, tenant_id: Uuid) -> PluginResult<()> {
|
||||
let tenant_id_str = tenant_id.to_string();
|
||||
|
||||
let ctx = ExecutionContext {
|
||||
tenant_id,
|
||||
user_id: Uuid::nil(),
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
self.execute_wasm(plugin_id, &ctx, move |store, instance| {
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_on_tenant_created(store, &tenant_id_str)
|
||||
.map_err(|e| PluginError::ExecutionError(e.to_string()))?
|
||||
.map_err(|e| PluginError::ExecutionError(e))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 禁用插件(停止事件监听 + 更新状态)
|
||||
pub async fn disable(&self, plugin_id: &str) -> PluginResult<()> {
|
||||
let loaded = self.get_loaded(plugin_id)?;
|
||||
|
||||
// 取消所有事件监听
|
||||
let mut handles = loaded.event_handles.write().await;
|
||||
for handle in handles.drain(..) {
|
||||
handle.abort();
|
||||
}
|
||||
drop(handles);
|
||||
|
||||
*loaded.status.write().await = PluginStatus::Disabled;
|
||||
tracing::info!(plugin_id, "Plugin disabled");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从内存卸载插件
|
||||
pub async fn unload(&self, plugin_id: &str) -> PluginResult<()> {
|
||||
if self.plugins.contains_key(plugin_id) {
|
||||
self.disable(plugin_id).await.ok();
|
||||
}
|
||||
self.plugins.remove(plugin_id);
|
||||
tracing::info!(plugin_id, "Plugin unloaded");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 健康检查
|
||||
pub async fn health_check(&self, plugin_id: &str) -> PluginResult<serde_json::Value> {
|
||||
let loaded = self.get_loaded(plugin_id)?;
|
||||
let status = loaded.status.read().await;
|
||||
match &*status {
|
||||
PluginStatus::Running => Ok(json!({
|
||||
"status": "healthy",
|
||||
"plugin_id": plugin_id,
|
||||
})),
|
||||
PluginStatus::Error(e) => Ok(json!({
|
||||
"status": "error",
|
||||
"plugin_id": plugin_id,
|
||||
"error": e,
|
||||
})),
|
||||
other => Ok(json!({
|
||||
"status": "unhealthy",
|
||||
"plugin_id": plugin_id,
|
||||
"state": format!("{:?}", other),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出所有已加载插件的信息
|
||||
pub fn list_plugins(&self) -> Vec<PluginInfo> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let loaded = entry.value();
|
||||
PluginInfo {
|
||||
id: loaded.id.clone(),
|
||||
name: loaded.manifest.metadata.name.clone(),
|
||||
version: loaded.manifest.metadata.version.clone(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 获取插件清单
|
||||
pub fn get_manifest(&self, plugin_id: &str) -> Option<PluginManifest> {
|
||||
self.plugins
|
||||
.get(plugin_id)
|
||||
.map(|entry| entry.manifest.clone())
|
||||
}
|
||||
|
||||
/// 检查插件是否正在运行
|
||||
pub async fn is_running(&self, plugin_id: &str) -> bool {
|
||||
if let Some(loaded) = self.plugins.get(plugin_id) {
|
||||
matches!(*loaded.status.read().await, PluginStatus::Running)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复数据库中状态为 running/enabled 的插件。
|
||||
///
|
||||
/// 服务器重启后调用此方法,重新加载 WASM 到内存并启动事件监听。
|
||||
pub async fn recover_plugins(
|
||||
&self,
|
||||
db: &DatabaseConnection,
|
||||
) -> PluginResult<Vec<String>> {
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use crate::entity::plugin;
|
||||
|
||||
// 查询所有运行中的插件
|
||||
let running_plugins = plugin::Entity::find()
|
||||
.filter(plugin::Column::Status.eq("running"))
|
||||
.filter(plugin::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let mut recovered = Vec::new();
|
||||
for model in running_plugins {
|
||||
let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
let plugin_id_str = &manifest.metadata.id;
|
||||
|
||||
// 加载 WASM 到内存
|
||||
if let Err(e) = self.load(plugin_id_str, &model.wasm_binary, manifest.clone()).await {
|
||||
tracing::error!(
|
||||
plugin_id = %plugin_id_str,
|
||||
error = %e,
|
||||
"Failed to recover plugin (load)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 初始化
|
||||
if let Err(e) = self.initialize(plugin_id_str).await {
|
||||
tracing::error!(
|
||||
plugin_id = %plugin_id_str,
|
||||
error = %e,
|
||||
"Failed to recover plugin (initialize)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 启动事件监听
|
||||
if let Err(e) = self.start_event_listener(plugin_id_str).await {
|
||||
tracing::error!(
|
||||
plugin_id = %plugin_id_str,
|
||||
error = %e,
|
||||
"Failed to recover plugin (start_event_listener)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!(plugin_id = %plugin_id_str, "Plugin recovered");
|
||||
recovered.push(plugin_id_str.clone());
|
||||
}
|
||||
|
||||
tracing::info!(count = recovered.len(), "Plugins recovered");
|
||||
Ok(recovered)
|
||||
}
|
||||
|
||||
// ---- 内部方法 ----
|
||||
|
||||
fn get_loaded(&self, plugin_id: &str) -> PluginResult<Arc<LoadedPlugin>> {
|
||||
self.plugins
|
||||
.get(plugin_id)
|
||||
.map(|e| e.value().clone())
|
||||
.ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))
|
||||
}
|
||||
|
||||
/// 在 spawn_blocking + catch_unwind + fuel + timeout 中执行 WASM 操作,
|
||||
/// 执行完成后自动刷新 pending_ops 到数据库。
|
||||
async fn execute_wasm<F, R>(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
exec_ctx: &ExecutionContext,
|
||||
operation: F,
|
||||
) -> PluginResult<R>
|
||||
where
|
||||
F: FnOnce(&mut Store<HostState>, &PluginWorld) -> PluginResult<R>
|
||||
+ Send
|
||||
+ std::panic::UnwindSafe
|
||||
+ 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
let loaded = self.get_loaded(plugin_id)?;
|
||||
|
||||
// 创建新的 Store + HostState,使用真实的租户/用户上下文
|
||||
let state = HostState::new(
|
||||
plugin_id.to_string(),
|
||||
exec_ctx.tenant_id,
|
||||
exec_ctx.user_id,
|
||||
exec_ctx.permissions.clone(),
|
||||
);
|
||||
let mut store = Store::new(&self.engine, state);
|
||||
store
|
||||
.set_fuel(self.config.default_fuel)
|
||||
.map_err(|e| PluginError::ExecutionError(e.to_string()))?;
|
||||
store.limiter(|state| &mut state.limits);
|
||||
|
||||
// 实例化
|
||||
let instance = PluginWorld::instantiate_async(&mut store, &loaded.component, &loaded.linker)
|
||||
.await
|
||||
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
|
||||
|
||||
let timeout_secs = self.config.execution_timeout_secs;
|
||||
let pid_owned = plugin_id.to_owned();
|
||||
|
||||
// spawn_blocking 闭包执行 WASM,正常完成时收集 pending_ops
|
||||
let (result, pending_ops): (PluginResult<R>, Vec<PendingOp>) =
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
tokio::task::spawn_blocking(move || {
|
||||
match std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
let r = operation(&mut store, &instance);
|
||||
// catch_unwind 内部不能调用 into_data(需要 &mut self),
|
||||
// 但这里 operation 已完成,store 仍可用
|
||||
let ops = std::mem::take(&mut store.data_mut().pending_ops);
|
||||
(r, ops)
|
||||
})) {
|
||||
Ok((r, ops)) => (r, ops),
|
||||
Err(_) => {
|
||||
// panic 后丢弃所有 pending_ops,避免半完成状态写入数据库
|
||||
tracing::warn!(plugin = %pid_owned, "WASM panic, discarding pending ops");
|
||||
(
|
||||
Err(PluginError::ExecutionError("WASM panic".to_string())),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
PluginError::ExecutionError(format!("插件执行超时 ({}s)", timeout_secs))
|
||||
})?
|
||||
.map_err(|e| PluginError::ExecutionError(e.to_string()))?;
|
||||
|
||||
// 刷新写操作到数据库
|
||||
Self::flush_ops(
|
||||
&self.db,
|
||||
plugin_id,
|
||||
pending_ops,
|
||||
exec_ctx.tenant_id,
|
||||
exec_ctx.user_id,
|
||||
&self.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 刷新 HostState 中的 pending_ops 到数据库。
|
||||
///
|
||||
/// 使用事务包裹所有数据库操作确保原子性。
|
||||
/// 事件发布在事务提交后执行(best-effort)。
|
||||
pub(crate) async fn flush_ops(
|
||||
db: &DatabaseConnection,
|
||||
plugin_id: &str,
|
||||
ops: Vec<PendingOp>,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
event_bus: &EventBus,
|
||||
) -> PluginResult<()> {
|
||||
if ops.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 使用事务确保所有数据库操作的原子性
|
||||
let txn = db.begin().await.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
for op in &ops {
|
||||
match op {
|
||||
PendingOp::Insert { id, entity, data } => {
|
||||
let table_name = DynamicTableManager::table_name(plugin_id, entity);
|
||||
let parsed_data: serde_json::Value =
|
||||
serde_json::from_slice(data).unwrap_or_default();
|
||||
let id_uuid = id.parse::<Uuid>().map_err(|e| {
|
||||
PluginError::ExecutionError(format!("无效的 ID: {}", e))
|
||||
})?;
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_insert_sql_with_id(&table_name, id_uuid, tenant_id, user_id, &parsed_data);
|
||||
txn.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
tracing::debug!(
|
||||
plugin_id,
|
||||
entity = %entity,
|
||||
"Flushed INSERT op"
|
||||
);
|
||||
}
|
||||
PendingOp::Update {
|
||||
entity,
|
||||
id,
|
||||
data,
|
||||
version,
|
||||
} => {
|
||||
let table_name = DynamicTableManager::table_name(plugin_id, entity);
|
||||
let parsed_data: serde_json::Value =
|
||||
serde_json::from_slice(data).unwrap_or_default();
|
||||
let id_uuid = id.parse::<Uuid>().map_err(|e| {
|
||||
PluginError::ExecutionError(format!("无效的 ID: {}", e))
|
||||
})?;
|
||||
let (sql, values) = DynamicTableManager::build_update_sql(
|
||||
&table_name,
|
||||
id_uuid,
|
||||
tenant_id,
|
||||
user_id,
|
||||
&parsed_data,
|
||||
*version as i32,
|
||||
);
|
||||
txn.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
tracing::debug!(
|
||||
plugin_id,
|
||||
entity = %entity,
|
||||
id = %id,
|
||||
"Flushed UPDATE op"
|
||||
);
|
||||
}
|
||||
PendingOp::Delete { entity, id } => {
|
||||
let table_name = DynamicTableManager::table_name(plugin_id, entity);
|
||||
let id_uuid = id.parse::<Uuid>().map_err(|e| {
|
||||
PluginError::ExecutionError(format!("无效的 ID: {}", e))
|
||||
})?;
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_delete_sql(&table_name, id_uuid, tenant_id);
|
||||
txn.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
tracing::debug!(
|
||||
plugin_id,
|
||||
entity = %entity,
|
||||
id = %id,
|
||||
"Flushed DELETE op"
|
||||
);
|
||||
}
|
||||
PendingOp::PublishEvent { .. } => {
|
||||
// 事件发布在事务提交后处理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
txn.commit().await.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
// 事务提交成功后发布事件(best-effort,不阻塞主流程)
|
||||
for op in ops {
|
||||
if let PendingOp::PublishEvent { event_type, payload } = op {
|
||||
let parsed_payload: serde_json::Value =
|
||||
serde_json::from_slice(&payload).unwrap_or_default();
|
||||
let event = erp_core::events::DomainEvent::new(
|
||||
&event_type,
|
||||
tenant_id,
|
||||
parsed_payload,
|
||||
);
|
||||
event_bus.publish(event, db).await;
|
||||
|
||||
tracing::debug!(
|
||||
plugin_id,
|
||||
event_type = %event_type,
|
||||
"Flushed PUBLISH_EVENT op"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 插件信息摘要
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct PluginInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
3
crates/erp-plugin/src/entity/mod.rs
Normal file
3
crates/erp-plugin/src/entity/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod plugin;
|
||||
pub mod plugin_entity;
|
||||
pub mod plugin_event_subscription;
|
||||
54
crates/erp-plugin/src/entity/plugin.rs
Normal file
54
crates/erp-plugin/src/entity/plugin.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "plugins")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
#[sea_orm(column_name = "plugin_version")]
|
||||
pub plugin_version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
pub status: String,
|
||||
pub manifest_json: serde_json::Value,
|
||||
#[serde(skip)]
|
||||
pub wasm_binary: Vec<u8>,
|
||||
pub wasm_hash: String,
|
||||
pub config_json: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub installed_at: Option<DateTimeUtc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::plugin_entity::Entity")]
|
||||
PluginEntity,
|
||||
#[sea_orm(has_many = "super::plugin_event_subscription::Entity")]
|
||||
PluginEventSubscription,
|
||||
}
|
||||
|
||||
impl Related<super::plugin_entity::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::PluginEntity.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
41
crates/erp-plugin/src/entity/plugin_entity.rs
Normal file
41
crates/erp-plugin/src/entity/plugin_entity.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "plugin_entities")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub plugin_id: Uuid,
|
||||
pub entity_name: String,
|
||||
pub table_name: String,
|
||||
pub schema_json: serde_json::Value,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::plugin::Entity",
|
||||
from = "Column::PluginId",
|
||||
to = "super::plugin::Column::Id"
|
||||
)]
|
||||
Plugin,
|
||||
}
|
||||
|
||||
impl Related<super::plugin::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Plugin.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
30
crates/erp-plugin/src/entity/plugin_event_subscription.rs
Normal file
30
crates/erp-plugin/src/entity/plugin_event_subscription.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "plugin_event_subscriptions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub plugin_id: Uuid,
|
||||
pub event_pattern: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::plugin::Entity",
|
||||
from = "Column::PluginId",
|
||||
to = "super::plugin::Column::Id"
|
||||
)]
|
||||
Plugin,
|
||||
}
|
||||
|
||||
impl Related<super::plugin::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Plugin.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
51
crates/erp-plugin/src/error.rs
Normal file
51
crates/erp-plugin/src/error.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use erp_core::error::AppError;
|
||||
|
||||
/// 插件模块错误类型
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginError {
|
||||
#[error("插件未找到: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("插件已存在: {0}")]
|
||||
AlreadyExists(String),
|
||||
|
||||
#[error("无效的插件清单: {0}")]
|
||||
InvalidManifest(String),
|
||||
|
||||
#[error("无效的插件状态: 期望 {expected}, 实际 {actual}")]
|
||||
InvalidState { expected: String, actual: String },
|
||||
|
||||
#[error("插件执行错误: {0}")]
|
||||
ExecutionError(String),
|
||||
|
||||
#[error("插件实例化错误: {0}")]
|
||||
InstantiationError(String),
|
||||
|
||||
#[error("插件 Fuel 耗尽: {0}")]
|
||||
FuelExhausted(String),
|
||||
|
||||
#[error("依赖未满足: {0}")]
|
||||
DependencyNotSatisfied(String),
|
||||
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
#[error("权限不足: {0}")]
|
||||
PermissionDenied(String),
|
||||
}
|
||||
|
||||
impl From<PluginError> for AppError {
|
||||
fn from(err: PluginError) -> Self {
|
||||
match &err {
|
||||
PluginError::NotFound(_) => AppError::NotFound(err.to_string()),
|
||||
PluginError::AlreadyExists(_) => AppError::Conflict(err.to_string()),
|
||||
PluginError::InvalidManifest(_)
|
||||
| PluginError::InvalidState { .. }
|
||||
| PluginError::DependencyNotSatisfied(_) => AppError::Validation(err.to_string()),
|
||||
PluginError::PermissionDenied(_) => AppError::Forbidden(err.to_string()),
|
||||
_ => AppError::Internal(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type PluginResult<T> = Result<T, PluginError>;
|
||||
194
crates/erp-plugin/src/handler/data_handler.rs
Normal file
194
crates/erp-plugin/src/handler/data_handler.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::data_dto::{CreatePluginDataReq, PluginDataListParams, PluginDataResp, UpdatePluginDataReq};
|
||||
use crate::data_service::PluginDataService;
|
||||
use crate::state::PluginState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}",
|
||||
params(PluginDataListParams),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<PluginDataResp>>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// GET /api/v1/plugins/{plugin_id}/{entity} — 列表
|
||||
pub async fn list_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
||||
Query(params): Query<PluginDataListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PluginDataResp>>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
|
||||
let (items, total) = PluginDataService::list(
|
||||
plugin_id,
|
||||
&entity,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: items,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages: (total as f64 / page_size as f64).ceil() as u64,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}",
|
||||
request_body = CreatePluginDataReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<PluginDataResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// POST /api/v1/plugins/{plugin_id}/{entity} — 创建
|
||||
pub async fn create_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
||||
Json(req): Json<CreatePluginDataReq>,
|
||||
) -> Result<Json<ApiResponse<PluginDataResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
let result = PluginDataService::create(
|
||||
plugin_id,
|
||||
&entity,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.data,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/{id}",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PluginDataResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// GET /api/v1/plugins/{plugin_id}/{entity}/{id} — 详情
|
||||
pub async fn get_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>,
|
||||
) -> Result<Json<ApiResponse<PluginDataResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
|
||||
let result =
|
||||
PluginDataService::get_by_id(plugin_id, &entity, id, ctx.tenant_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/{id}",
|
||||
request_body = UpdatePluginDataReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<PluginDataResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// PUT /api/v1/plugins/{plugin_id}/{entity}/{id} — 更新
|
||||
pub async fn update_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>,
|
||||
Json(req): Json<UpdatePluginDataReq>,
|
||||
) -> Result<Json<ApiResponse<PluginDataResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
let result = PluginDataService::update(
|
||||
plugin_id,
|
||||
&entity,
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.data,
|
||||
req.version,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/{id}",
|
||||
responses(
|
||||
(status = 200, description = "删除成功"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} — 删除
|
||||
pub async fn delete_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
PluginDataService::delete(
|
||||
plugin_id,
|
||||
&entity,
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
2
crates/erp-plugin/src/handler/mod.rs
Normal file
2
crates/erp-plugin/src/handler/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod data_handler;
|
||||
pub mod plugin_handler;
|
||||
379
crates/erp-plugin/src/handler/plugin_handler.rs
Normal file
379
crates/erp-plugin/src/handler/plugin_handler.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Multipart, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
|
||||
use crate::dto::{
|
||||
PluginHealthResp, PluginListParams, PluginResp, UpdatePluginConfigReq,
|
||||
};
|
||||
use crate::service::PluginService;
|
||||
use crate::state::PluginState;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/upload",
|
||||
request_body(content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "上传成功", body = ApiResponse<PluginResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/upload — 上传插件 (multipart: wasm + manifest)
|
||||
pub async fn upload_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
let mut wasm_binary: Option<Vec<u8>> = None;
|
||||
let mut manifest_toml: Option<String> = None;
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| {
|
||||
AppError::Validation(format!("Multipart 解析失败: {}", e))
|
||||
})? {
|
||||
let name = field.name().unwrap_or("");
|
||||
match name {
|
||||
"wasm" => {
|
||||
wasm_binary = Some(field.bytes().await.map_err(|e| {
|
||||
AppError::Validation(format!("读取 WASM 文件失败: {}", e))
|
||||
})?.to_vec());
|
||||
}
|
||||
"manifest" => {
|
||||
let text = field.text().await.map_err(|e| {
|
||||
AppError::Validation(format!("读取 Manifest 失败: {}", e))
|
||||
})?;
|
||||
manifest_toml = Some(text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let wasm = wasm_binary.ok_or_else(|| {
|
||||
AppError::Validation("缺少 wasm 文件".to_string())
|
||||
})?;
|
||||
let manifest = manifest_toml.ok_or_else(|| {
|
||||
AppError::Validation("缺少 manifest 文件".to_string())
|
||||
})?;
|
||||
|
||||
let result = PluginService::upload(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
wasm,
|
||||
&manifest,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins",
|
||||
params(PluginListParams),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<PluginResp>>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins — 列表
|
||||
pub async fn list_plugins<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<PluginListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PluginResp>>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
|
||||
let pagination = Pagination {
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
|
||||
let (plugins, total) = PluginService::list(
|
||||
ctx.tenant_id,
|
||||
pagination.page.unwrap_or(1),
|
||||
pagination.page_size.unwrap_or(20),
|
||||
params.status.as_deref(),
|
||||
params.search.as_deref(),
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: plugins,
|
||||
total,
|
||||
page: pagination.page.unwrap_or(1),
|
||||
page_size: pagination.page_size.unwrap_or(20),
|
||||
total_pages: (total as f64 / pagination.page_size.unwrap_or(20) as f64).ceil() as u64,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins/{id}",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins/{id} — 详情
|
||||
pub async fn get_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
let result = PluginService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins/{id}/schema",
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins/{id}/schema — 实体 schema
|
||||
pub async fn get_plugin_schema<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
let schema = PluginService::get_schema(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(schema)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/install",
|
||||
responses(
|
||||
(status = 200, description = "安装成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/install — 安装
|
||||
pub async fn install_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result = PluginService::install(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.engine,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/enable",
|
||||
responses(
|
||||
(status = 200, description = "启用成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/enable — 启用
|
||||
pub async fn enable_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result = PluginService::enable(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.engine,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/disable",
|
||||
responses(
|
||||
(status = 200, description = "停用成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/disable — 停用
|
||||
pub async fn disable_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result = PluginService::disable(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.engine,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/uninstall",
|
||||
responses(
|
||||
(status = 200, description = "卸载成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/uninstall — 卸载
|
||||
pub async fn uninstall_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result = PluginService::uninstall(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.engine,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/admin/plugins/{id}",
|
||||
responses(
|
||||
(status = 200, description = "清除成功"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// DELETE /api/v1/admin/plugins/{id} — 清除(软删除)
|
||||
pub async fn purge_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
PluginService::purge(id, ctx.tenant_id, ctx.user_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins/{id}/health",
|
||||
responses(
|
||||
(status = 200, description = "健康检查", body = ApiResponse<PluginHealthResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins/{id}/health — 健康检查
|
||||
pub async fn health_check_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginHealthResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
let result = PluginService::health_check(id, ctx.tenant_id, &state.db, &state.engine).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/admin/plugins/{id}/config",
|
||||
request_body = UpdatePluginConfigReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// PUT /api/v1/admin/plugins/{id}/config — 更新配置
|
||||
pub async fn update_plugin_config<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdatePluginConfigReq>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result = PluginService::update_config(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.config,
|
||||
req.version,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
170
crates/erp-plugin/src/host.rs
Normal file
170
crates/erp-plugin/src/host.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
use wasmtime::StoreLimits;
|
||||
|
||||
use crate::erp::plugin::host_api;
|
||||
|
||||
/// 待刷新的写操作
|
||||
#[derive(Debug)]
|
||||
pub enum PendingOp {
|
||||
Insert {
|
||||
id: String,
|
||||
entity: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
Update {
|
||||
entity: String,
|
||||
id: String,
|
||||
data: Vec<u8>,
|
||||
version: i64,
|
||||
},
|
||||
Delete {
|
||||
entity: String,
|
||||
id: String,
|
||||
},
|
||||
PublishEvent {
|
||||
event_type: String,
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Host 端状态 — 绑定到每个 WASM Store 实例
|
||||
///
|
||||
/// 采用延迟执行模式:
|
||||
/// - 读操作 (db_query, config_get, current_user) → 调用前预填充
|
||||
/// - 写操作 (db_insert, db_update, db_delete, event_publish) → 入队 pending_ops
|
||||
/// - WASM 调用结束后由 engine 刷新 pending_ops 执行真实 DB 操作
|
||||
pub struct HostState {
|
||||
pub(crate) limits: StoreLimits,
|
||||
#[allow(dead_code)]
|
||||
pub(crate) tenant_id: Uuid,
|
||||
#[allow(dead_code)]
|
||||
pub(crate) user_id: Uuid,
|
||||
pub(crate) permissions: Vec<String>,
|
||||
pub(crate) plugin_id: String,
|
||||
// 预填充的读取缓存
|
||||
pub(crate) query_results: HashMap<String, Vec<u8>>,
|
||||
pub(crate) config_cache: HashMap<String, Vec<u8>>,
|
||||
pub(crate) current_user_json: Vec<u8>,
|
||||
// 待刷新的写操作
|
||||
pub(crate) pending_ops: Vec<PendingOp>,
|
||||
// 日志
|
||||
pub(crate) logs: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl HostState {
|
||||
pub fn new(
|
||||
plugin_id: String,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
permissions: Vec<String>,
|
||||
) -> Self {
|
||||
let current_user = serde_json::json!({
|
||||
"id": user_id.to_string(),
|
||||
"tenant_id": tenant_id.to_string(),
|
||||
});
|
||||
Self {
|
||||
limits: wasmtime::StoreLimitsBuilder::new().build(),
|
||||
tenant_id,
|
||||
user_id,
|
||||
permissions,
|
||||
plugin_id,
|
||||
query_results: HashMap::new(),
|
||||
config_cache: HashMap::new(),
|
||||
current_user_json: serde_json::to_vec(¤t_user).unwrap_or_default(),
|
||||
pending_ops: Vec::new(),
|
||||
logs: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 实现 bindgen 生成的 Host trait — 插件调用 Host API 的入口
|
||||
impl host_api::Host for HostState {
|
||||
fn db_insert(&mut self, entity: String, data: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
let id = Uuid::now_v7().to_string();
|
||||
let response = serde_json::json!({
|
||||
"id": id,
|
||||
"entity": entity,
|
||||
"status": "queued",
|
||||
});
|
||||
self.pending_ops.push(PendingOp::Insert {
|
||||
id: id.clone(),
|
||||
entity,
|
||||
data,
|
||||
});
|
||||
serde_json::to_vec(&response).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn db_query(
|
||||
&mut self,
|
||||
entity: String,
|
||||
_filter: Vec<u8>,
|
||||
_pagination: Vec<u8>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
self.query_results
|
||||
.get(&entity)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity))
|
||||
}
|
||||
|
||||
fn db_update(
|
||||
&mut self,
|
||||
entity: String,
|
||||
id: String,
|
||||
data: Vec<u8>,
|
||||
version: i64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let response = serde_json::json!({
|
||||
"id": id,
|
||||
"entity": entity,
|
||||
"version": version + 1,
|
||||
"status": "queued",
|
||||
});
|
||||
self.pending_ops.push(PendingOp::Update {
|
||||
entity,
|
||||
id,
|
||||
data,
|
||||
version,
|
||||
});
|
||||
serde_json::to_vec(&response).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn db_delete(&mut self, entity: String, id: String) -> Result<(), String> {
|
||||
self.pending_ops.push(PendingOp::Delete { entity, id });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn event_publish(&mut self, event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||||
self.pending_ops.push(PendingOp::PublishEvent {
|
||||
event_type,
|
||||
payload,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_get(&mut self, key: String) -> Result<Vec<u8>, String> {
|
||||
self.config_cache
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("配置项 '{}' 未预填充", key))
|
||||
}
|
||||
|
||||
fn log_write(&mut self, level: String, message: String) {
|
||||
tracing::info!(
|
||||
plugin = %self.plugin_id,
|
||||
level = %level,
|
||||
"Plugin log: {}",
|
||||
message
|
||||
);
|
||||
self.logs.push((level, message));
|
||||
}
|
||||
|
||||
fn current_user(&mut self) -> Result<Vec<u8>, String> {
|
||||
Ok(self.current_user_json.clone())
|
||||
}
|
||||
|
||||
fn check_permission(&mut self, permission: String) -> Result<bool, String> {
|
||||
Ok(self.permissions.contains(&permission))
|
||||
}
|
||||
}
|
||||
24
crates/erp-plugin/src/lib.rs
Normal file
24
crates/erp-plugin/src/lib.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
//! ERP WASM 插件运行时 — 生产级 Host API
|
||||
//!
|
||||
//! 完整插件管理链路:加载 → 初始化 → 运行 → 停用 → 卸载
|
||||
|
||||
// bindgen! 生成类型化绑定(包含 Host trait 和 PluginWorld 类型)
|
||||
// 生成: erp::plugin::host_api::Host trait, PluginWorld 类型
|
||||
wasmtime::component::bindgen!({
|
||||
path: "wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
pub mod data_dto;
|
||||
pub mod data_service;
|
||||
pub mod dynamic_table;
|
||||
pub mod dto;
|
||||
pub mod engine;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod handler;
|
||||
pub mod host;
|
||||
pub mod manifest;
|
||||
pub mod module;
|
||||
pub mod service;
|
||||
pub mod state;
|
||||
262
crates/erp-plugin/src/manifest.rs
Normal file
262
crates/erp-plugin/src/manifest.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{PluginError, PluginResult};
|
||||
|
||||
/// 插件清单 — 从 TOML 文件解析
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginManifest {
|
||||
pub metadata: PluginMetadata,
|
||||
pub schema: Option<PluginSchema>,
|
||||
pub events: Option<PluginEvents>,
|
||||
pub ui: Option<PluginUi>,
|
||||
pub permissions: Option<Vec<PluginPermission>>,
|
||||
}
|
||||
|
||||
/// 插件元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginMetadata {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub author: String,
|
||||
#[serde(default)]
|
||||
pub min_platform_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
/// 插件 Schema — 定义动态实体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginSchema {
|
||||
pub entities: Vec<PluginEntity>,
|
||||
}
|
||||
|
||||
/// 插件实体定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginEntity {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub fields: Vec<PluginField>,
|
||||
#[serde(default)]
|
||||
pub indexes: Vec<PluginIndex>,
|
||||
}
|
||||
|
||||
/// 插件字段定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginField {
|
||||
pub name: String,
|
||||
pub field_type: PluginFieldType,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
#[serde(default)]
|
||||
pub unique: bool,
|
||||
pub default: Option<serde_json::Value>,
|
||||
pub display_name: Option<String>,
|
||||
pub ui_widget: Option<String>,
|
||||
pub options: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// 字段类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginFieldType {
|
||||
String,
|
||||
Integer,
|
||||
Float,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Json,
|
||||
Uuid,
|
||||
Decimal,
|
||||
}
|
||||
|
||||
/// 索引定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginIndex {
|
||||
pub name: String,
|
||||
pub fields: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub unique: bool,
|
||||
}
|
||||
|
||||
/// 事件订阅配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginEvents {
|
||||
pub subscribe: Vec<String>,
|
||||
}
|
||||
|
||||
/// UI 页面配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginUi {
|
||||
pub pages: Vec<PluginPage>,
|
||||
}
|
||||
|
||||
/// 插件页面定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginPage {
|
||||
pub route: String,
|
||||
pub entity: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub icon: String,
|
||||
#[serde(default)]
|
||||
pub menu_group: Option<String>,
|
||||
}
|
||||
|
||||
/// 权限定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginPermission {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// 从 TOML 字符串解析插件清单
|
||||
pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
|
||||
let manifest: PluginManifest =
|
||||
toml::from_str(toml_str).map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
// 验证必填字段
|
||||
if manifest.metadata.id.is_empty() {
|
||||
return Err(PluginError::InvalidManifest("metadata.id 不能为空".to_string()));
|
||||
}
|
||||
if manifest.metadata.name.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"metadata.name 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 验证实体名称
|
||||
if let Some(schema) = &manifest.schema {
|
||||
for entity in &schema.entities {
|
||||
if entity.name.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"entity.name 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
// 验证实体名称只包含合法字符
|
||||
if !entity
|
||||
.name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
{
|
||||
return Err(PluginError::InvalidManifest(format!(
|
||||
"entity.name '{}' 只能包含字母、数字和下划线",
|
||||
entity.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_manifest() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test-plugin"
|
||||
name = "测试插件"
|
||||
version = "0.1.0"
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
assert_eq!(manifest.metadata.id, "test-plugin");
|
||||
assert_eq!(manifest.metadata.name, "测试插件");
|
||||
assert!(manifest.schema.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_manifest() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "inventory"
|
||||
name = "进销存"
|
||||
version = "1.0.0"
|
||||
description = "简单进销存管理"
|
||||
author = "ERP Team"
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "product"
|
||||
display_name = "商品"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "sku"
|
||||
field_type = "string"
|
||||
required = true
|
||||
unique = true
|
||||
display_name = "SKU 编码"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "price"
|
||||
field_type = "decimal"
|
||||
required = true
|
||||
display_name = "价格"
|
||||
|
||||
[events]
|
||||
subscribe = ["workflow.task.completed", "order.*"]
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
route = "/products"
|
||||
entity = "product"
|
||||
display_name = "商品管理"
|
||||
icon = "ShoppingOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[permissions]]
|
||||
code = "product.list"
|
||||
name = "查看商品"
|
||||
description = "查看商品列表"
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
assert_eq!(manifest.metadata.id, "inventory");
|
||||
let schema = manifest.schema.unwrap();
|
||||
assert_eq!(schema.entities.len(), 1);
|
||||
assert_eq!(schema.entities[0].name, "product");
|
||||
assert_eq!(schema.entities[0].fields.len(), 2);
|
||||
let events = manifest.events.unwrap();
|
||||
assert_eq!(events.subscribe.len(), 2);
|
||||
let ui = manifest.ui.unwrap();
|
||||
assert_eq!(ui.pages.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_empty_id() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = ""
|
||||
name = "测试"
|
||||
version = "0.1.0"
|
||||
"#;
|
||||
let result = parse_manifest(toml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_invalid_entity_name() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "测试"
|
||||
version = "0.1.0"
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "my-table"
|
||||
display_name = "表格"
|
||||
"#;
|
||||
let result = parse_manifest(toml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
83
crates/erp-plugin/src/module.rs
Normal file
83
crates/erp-plugin/src/module.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use async_trait::async_trait;
|
||||
use axum::Router;
|
||||
use axum::routing::{get, post, put};
|
||||
use erp_core::module::ErpModule;
|
||||
|
||||
pub struct PluginModule;
|
||||
|
||||
#[async_trait]
|
||||
impl ErpModule for PluginModule {
|
||||
fn name(&self) -> &str {
|
||||
"plugin"
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
vec!["auth", "config"]
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginModule {
|
||||
/// 插件管理路由(需要 JWT 认证)
|
||||
pub fn protected_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::state::PluginState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let admin_routes = Router::new()
|
||||
.route("/admin/plugins/upload", post(crate::handler::plugin_handler::upload_plugin::<S>))
|
||||
.route("/admin/plugins", get(crate::handler::plugin_handler::list_plugins::<S>))
|
||||
.route(
|
||||
"/admin/plugins/{id}",
|
||||
get(crate::handler::plugin_handler::get_plugin::<S>)
|
||||
.delete(crate::handler::plugin_handler::purge_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/schema",
|
||||
get(crate::handler::plugin_handler::get_plugin_schema::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/install",
|
||||
post(crate::handler::plugin_handler::install_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/enable",
|
||||
post(crate::handler::plugin_handler::enable_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/disable",
|
||||
post(crate::handler::plugin_handler::disable_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/uninstall",
|
||||
post(crate::handler::plugin_handler::uninstall_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/health",
|
||||
get(crate::handler::plugin_handler::health_check_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/config",
|
||||
put(crate::handler::plugin_handler::update_plugin_config::<S>),
|
||||
);
|
||||
|
||||
// 插件数据 CRUD 路由
|
||||
let data_routes = Router::new()
|
||||
.route(
|
||||
"/plugins/{plugin_id}/{entity}",
|
||||
get(crate::handler::data_handler::list_plugin_data::<S>)
|
||||
.post(crate::handler::data_handler::create_plugin_data::<S>),
|
||||
)
|
||||
.route(
|
||||
"/plugins/{plugin_id}/{entity}/{id}",
|
||||
get(crate::handler::data_handler::get_plugin_data::<S>)
|
||||
.put(crate::handler::data_handler::update_plugin_data::<S>)
|
||||
.delete(crate::handler::data_handler::delete_plugin_data::<S>),
|
||||
);
|
||||
|
||||
admin_routes.merge(data_routes)
|
||||
}
|
||||
}
|
||||
555
crates/erp-plugin/src/service.rs
Normal file
555
crates/erp-plugin/src/service.rs
Normal file
@@ -0,0 +1,555 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
|
||||
use crate::dto::{
|
||||
PluginEntityResp, PluginHealthResp, PluginPermissionResp, PluginResp,
|
||||
};
|
||||
use crate::dynamic_table::DynamicTableManager;
|
||||
use crate::engine::PluginEngine;
|
||||
use crate::entity::{plugin, plugin_entity, plugin_event_subscription};
|
||||
use crate::error::PluginError;
|
||||
use crate::manifest::{parse_manifest, PluginManifest};
|
||||
|
||||
pub struct PluginService;
|
||||
|
||||
impl PluginService {
|
||||
/// 上传插件: 解析 manifest + 存储 wasm_binary + status=uploaded
|
||||
pub async fn upload(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
wasm_binary: Vec<u8>,
|
||||
manifest_toml: &str,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<PluginResp> {
|
||||
// 解析 manifest
|
||||
let manifest = parse_manifest(manifest_toml)?;
|
||||
|
||||
// 计算 WASM hash
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&wasm_binary);
|
||||
let wasm_hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
let now = Utc::now();
|
||||
let plugin_id = Uuid::now_v7();
|
||||
|
||||
// 序列化 manifest 为 JSON
|
||||
let manifest_json =
|
||||
serde_json::to_value(&manifest).map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
let model = plugin::ActiveModel {
|
||||
id: Set(plugin_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(manifest.metadata.name.clone()),
|
||||
plugin_version: Set(manifest.metadata.version.clone()),
|
||||
description: Set(if manifest.metadata.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(manifest.metadata.description.clone())
|
||||
}),
|
||||
author: Set(if manifest.metadata.author.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(manifest.metadata.author.clone())
|
||||
}),
|
||||
status: Set("uploaded".to_string()),
|
||||
manifest_json: Set(manifest_json),
|
||||
wasm_binary: Set(wasm_binary),
|
||||
wasm_hash: Set(wasm_hash),
|
||||
config_json: Set(serde_json::json!({})),
|
||||
error_message: Set(None),
|
||||
installed_at: Set(None),
|
||||
enabled_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Some(operator_id)),
|
||||
updated_by: Set(Some(operator_id)),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
|
||||
let model = model.insert(db).await?;
|
||||
|
||||
Ok(plugin_model_to_resp(&model, &manifest, vec![]))
|
||||
}
|
||||
|
||||
/// 安装插件: 创建动态表 + 注册 entity 记录 + 注册事件订阅 + status=installed
|
||||
pub async fn install(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
engine: &PluginEngine,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
validate_status(&model.status, "uploaded")?;
|
||||
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
// 创建动态表 + 注册 entity 记录
|
||||
let mut entity_resps = Vec::new();
|
||||
if let Some(schema) = &manifest.schema {
|
||||
for entity_def in &schema.entities {
|
||||
let table_name = DynamicTableManager::table_name(&manifest.metadata.id, &entity_def.name);
|
||||
|
||||
// 创建动态表
|
||||
DynamicTableManager::create_table(db, &manifest.metadata.id, entity_def).await?;
|
||||
|
||||
// 注册 entity 记录
|
||||
let entity_id = Uuid::now_v7();
|
||||
let entity_model = plugin_entity::ActiveModel {
|
||||
id: Set(entity_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
plugin_id: Set(plugin_id),
|
||||
entity_name: Set(entity_def.name.clone()),
|
||||
table_name: Set(table_name.clone()),
|
||||
schema_json: Set(serde_json::to_value(entity_def).unwrap_or_default()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Some(operator_id)),
|
||||
updated_by: Set(Some(operator_id)),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
entity_model.insert(db).await?;
|
||||
|
||||
entity_resps.push(PluginEntityResp {
|
||||
name: entity_def.name.clone(),
|
||||
display_name: entity_def.display_name.clone(),
|
||||
table_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 注册事件订阅
|
||||
if let Some(events) = &manifest.events {
|
||||
for pattern in &events.subscribe {
|
||||
let sub_id = Uuid::now_v7();
|
||||
let sub_model = plugin_event_subscription::ActiveModel {
|
||||
id: Set(sub_id),
|
||||
plugin_id: Set(plugin_id),
|
||||
event_pattern: Set(pattern.clone()),
|
||||
created_at: Set(now),
|
||||
};
|
||||
sub_model.insert(db).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载到内存
|
||||
engine
|
||||
.load(
|
||||
&manifest.metadata.id,
|
||||
&model.wasm_binary,
|
||||
manifest.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 更新状态
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.status = Set("installed".to_string());
|
||||
active.installed_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
let model = active.update(db).await?;
|
||||
|
||||
Ok(plugin_model_to_resp(&model, &manifest, entity_resps))
|
||||
}
|
||||
|
||||
/// 启用插件: engine.initialize + start_event_listener + status=running
|
||||
pub async fn enable(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
engine: &PluginEngine,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
validate_status_any(&model.status, &["installed", "disabled"])?;
|
||||
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
let plugin_manifest_id = &manifest.metadata.id;
|
||||
|
||||
// 如果之前是 disabled 状态,需要先卸载再重新加载到内存
|
||||
// (disable 只改内存状态但不从 DashMap 移除)
|
||||
if model.status == "disabled" {
|
||||
engine.unload(plugin_manifest_id).await.ok();
|
||||
engine
|
||||
.load(plugin_manifest_id, &model.wasm_binary, manifest.clone())
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 初始化
|
||||
engine.initialize(plugin_manifest_id).await?;
|
||||
|
||||
// 启动事件监听
|
||||
engine.start_event_listener(plugin_manifest_id).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.status = Set("running".to_string());
|
||||
active.enabled_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
active.error_message = Set(None);
|
||||
let model = active.update(db).await?;
|
||||
|
||||
let entity_resps = find_plugin_entities(plugin_id, tenant_id, db).await?;
|
||||
Ok(plugin_model_to_resp(&model, &manifest, entity_resps))
|
||||
}
|
||||
|
||||
/// 禁用插件: engine.disable + cancel 事件订阅 + status=disabled
|
||||
pub async fn disable(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
engine: &PluginEngine,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
validate_status_any(&model.status, &["running", "enabled"])?;
|
||||
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
// 禁用引擎
|
||||
engine.disable(&manifest.metadata.id).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.status = Set("disabled".to_string());
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
let model = active.update(db).await?;
|
||||
|
||||
let entity_resps = find_plugin_entities(plugin_id, tenant_id, db).await?;
|
||||
Ok(plugin_model_to_resp(&model, &manifest, entity_resps))
|
||||
}
|
||||
|
||||
/// 卸载插件: unload + 有条件地 drop 动态表 + status=uninstalled
|
||||
pub async fn uninstall(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
engine: &PluginEngine,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
validate_status_any(&model.status, &["installed", "disabled"])?;
|
||||
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
// 卸载(如果 disabled 状态,engine 可能仍在内存中)
|
||||
engine.unload(&manifest.metadata.id).await.ok();
|
||||
|
||||
// 软删除当前租户的 entity 记录
|
||||
let now = Utc::now();
|
||||
let tenant_entities = plugin_entity::Entity::find()
|
||||
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
for entity in &tenant_entities {
|
||||
let mut active: plugin_entity::ActiveModel = entity.clone().into();
|
||||
active.deleted_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
active.update(db).await?;
|
||||
}
|
||||
|
||||
// 仅当没有其他租户的活跃 entity 记录引用相同的 table_name 时才 drop 表
|
||||
if let Some(schema) = &manifest.schema {
|
||||
for entity_def in &schema.entities {
|
||||
let table_name =
|
||||
DynamicTableManager::table_name(&manifest.metadata.id, &entity_def.name);
|
||||
|
||||
// 检查是否还有其他租户的活跃 entity 记录引用此表
|
||||
let other_tenants_count = plugin_entity::Entity::find()
|
||||
.filter(plugin_entity::Column::TableName.eq(&table_name))
|
||||
.filter(plugin_entity::Column::TenantId.ne(tenant_id))
|
||||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
if other_tenants_count == 0 {
|
||||
// 没有其他租户使用,安全删除
|
||||
DynamicTableManager::drop_table(db, &manifest.metadata.id, &entity_def.name)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.status = Set("uninstalled".to_string());
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
let model = active.update(db).await?;
|
||||
|
||||
Ok(plugin_model_to_resp(&model, &manifest, vec![]))
|
||||
}
|
||||
|
||||
/// 列表查询
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
status: Option<&str>,
|
||||
search: Option<&str>,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<(Vec<PluginResp>, u64)> {
|
||||
let mut query = plugin::Entity::find()
|
||||
.filter(plugin::Column::TenantId.eq(tenant_id))
|
||||
.filter(plugin::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(s) = status {
|
||||
query = query.filter(plugin::Column::Status.eq(s));
|
||||
}
|
||||
if let Some(q) = search {
|
||||
query = query.filter(
|
||||
plugin::Column::Name.contains(q)
|
||||
.or(plugin::Column::Description.contains(q)),
|
||||
);
|
||||
}
|
||||
|
||||
let paginator = query
|
||||
.clone()
|
||||
.paginate(db, page_size);
|
||||
|
||||
let total = paginator.num_items().await?;
|
||||
let models = paginator
|
||||
.fetch_page(page.saturating_sub(1))
|
||||
.await?;
|
||||
|
||||
let mut resps = Vec::with_capacity(models.len());
|
||||
for model in models {
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone()).unwrap_or_else(|_| {
|
||||
PluginManifest {
|
||||
metadata: crate::manifest::PluginMetadata {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
version: String::new(),
|
||||
description: String::new(),
|
||||
author: String::new(),
|
||||
min_platform_version: None,
|
||||
dependencies: vec![],
|
||||
},
|
||||
schema: None,
|
||||
events: None,
|
||||
ui: None,
|
||||
permissions: None,
|
||||
}
|
||||
});
|
||||
let entities = find_plugin_entities(model.id, tenant_id, db).await.unwrap_or_default();
|
||||
resps.push(plugin_model_to_resp(&model, &manifest, entities));
|
||||
}
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// 按 ID 获取详情
|
||||
pub async fn get_by_id(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
let entities = find_plugin_entities(plugin_id, tenant_id, db).await?;
|
||||
Ok(plugin_model_to_resp(&model, &manifest, entities))
|
||||
}
|
||||
|
||||
/// 更新配置
|
||||
pub async fn update_config(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
config: serde_json::Value,
|
||||
expected_version: i32,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
|
||||
erp_core::error::check_version(expected_version, model.version)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.config_json = Set(config);
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
let model = active.update(db).await?;
|
||||
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
let entities = find_plugin_entities(plugin_id, tenant_id, db).await.unwrap_or_default();
|
||||
Ok(plugin_model_to_resp(&model, &manifest, entities))
|
||||
}
|
||||
|
||||
/// 健康检查
|
||||
pub async fn health_check(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
engine: &PluginEngine,
|
||||
) -> AppResult<PluginHealthResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
let details = engine.health_check(&manifest.metadata.id).await?;
|
||||
|
||||
Ok(PluginHealthResp {
|
||||
plugin_id,
|
||||
status: details
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取插件 Schema
|
||||
pub async fn get_schema(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<serde_json::Value> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
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())
|
||||
}
|
||||
|
||||
/// 清除插件记录(软删除,仅限已卸载状态)
|
||||
pub async fn purge(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
validate_status(&model.status, "uninstalled")?;
|
||||
let now = Utc::now();
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
active.update(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 内部辅助 ----
|
||||
|
||||
fn find_plugin(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> impl std::future::Future<Output = AppResult<plugin::Model>> + Send {
|
||||
async move {
|
||||
plugin::Entity::find_by_id(plugin_id)
|
||||
.one(db)
|
||||
.await?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| {
|
||||
erp_core::error::AppError::NotFound(format!("插件 {} 不存在", plugin_id))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_plugin_entities(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<Vec<PluginEntityResp>> {
|
||||
let entities = plugin_entity::Entity::find()
|
||||
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(entities
|
||||
.into_iter()
|
||||
.map(|e| PluginEntityResp {
|
||||
name: e.entity_name.clone(),
|
||||
display_name: e.entity_name,
|
||||
table_name: e.table_name,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn validate_status(actual: &str, expected: &str) -> AppResult<()> {
|
||||
if actual != expected {
|
||||
return Err(PluginError::InvalidState {
|
||||
expected: expected.to_string(),
|
||||
actual: actual.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_status_any(actual: &str, expected: &[&str]) -> AppResult<()> {
|
||||
if !expected.contains(&actual) {
|
||||
return Err(PluginError::InvalidState {
|
||||
expected: expected.join(" 或 "),
|
||||
actual: actual.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plugin_model_to_resp(
|
||||
model: &plugin::Model,
|
||||
manifest: &PluginManifest,
|
||||
entities: Vec<PluginEntityResp>,
|
||||
) -> PluginResp {
|
||||
let permissions = manifest.permissions.as_ref().map(|perms| {
|
||||
perms
|
||||
.iter()
|
||||
.map(|p| PluginPermissionResp {
|
||||
code: p.code.clone(),
|
||||
name: p.name.clone(),
|
||||
description: p.description.clone(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
PluginResp {
|
||||
id: model.id,
|
||||
name: model.name.clone(),
|
||||
version: model.plugin_version.clone(),
|
||||
description: model.description.clone(),
|
||||
author: model.author.clone(),
|
||||
status: model.status.clone(),
|
||||
config: model.config_json.clone(),
|
||||
installed_at: model.installed_at,
|
||||
enabled_at: model.enabled_at,
|
||||
entities,
|
||||
permissions,
|
||||
record_version: model.version,
|
||||
}
|
||||
}
|
||||
13
crates/erp-plugin/src/state.rs
Normal file
13
crates/erp-plugin/src/state.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::engine::PluginEngine;
|
||||
|
||||
/// 插件模块共享状态 — 用于 Axum State 提取
|
||||
#[derive(Clone)]
|
||||
pub struct PluginState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub engine: PluginEngine,
|
||||
}
|
||||
Reference in New Issue
Block a user