feat(plugin): 集成 WASM 插件系统到主服务并修复链路问题

- 新增 erp-plugin crate:插件管理、WASM 运行时、动态表、数据 CRUD
- 新增前端插件管理页面(PluginAdmin/PluginCRUDPage)和 API 层
- 新增插件数据迁移(plugins/plugin_entities/plugin_event_subscriptions)
- 新增权限补充迁移(为已有租户补充 plugin.admin/plugin.list 权限)
- 修复 PluginAdmin 页面 InstallOutlined 图标不存在的崩溃问题
- 修复 settings 唯一索引迁移顺序错误(先去重再建索引)
- 更新 wiki 和 CLAUDE.md 反映插件系统集成状态
- 新增 dev.ps1 一键启动脚本
This commit is contained in:
iven
2026-04-15 23:32:02 +08:00
parent 7e8fabb095
commit ff352a4c24
46 changed files with 6723 additions and 19 deletions

View File

@@ -0,0 +1,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>,
}

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

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

View File

@@ -0,0 +1,250 @@
use sea_orm::{ConnectionTrait, DatabaseConnection, FromQueryResult, Statement, Value};
use uuid::Uuid;
use crate::error::{PluginError, PluginResult};
use crate::manifest::PluginEntity;
/// 消毒标识符:只保留 ASCII 字母、数字、下划线,防止 SQL 注入
fn sanitize_identifier(input: &str) -> String {
input
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
.collect()
}
/// 动态表管理器 — 处理插件动态创建/删除的数据库表
pub struct DynamicTableManager;
impl DynamicTableManager {
/// 生成动态表名: `plugin_{sanitized_id}_{sanitized_entity}`
pub fn table_name(plugin_id: &str, entity_name: &str) -> String {
let sanitized_id = sanitize_identifier(plugin_id);
let sanitized_entity = sanitize_identifier(entity_name);
format!("plugin_{}_{}", sanitized_id, sanitized_entity)
}
/// 创建动态表
pub async fn create_table(
db: &DatabaseConnection,
plugin_id: &str,
entity: &PluginEntity,
) -> PluginResult<()> {
let table_name = Self::table_name(plugin_id, &entity.name);
// 创建表
let create_sql = format!(
"CREATE TABLE IF NOT EXISTS \"{table_name}\" (\
\"id\" UUID PRIMARY KEY, \
\"tenant_id\" UUID NOT NULL, \
\"data\" JSONB NOT NULL DEFAULT '{{}}', \
\"created_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
\"updated_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
\"created_by\" UUID, \
\"updated_by\" UUID, \
\"deleted_at\" TIMESTAMPTZ, \
\"version\" INT NOT NULL DEFAULT 1)"
);
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
create_sql,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
// 创建租户索引
let tenant_idx_sql = format!(
"CREATE INDEX IF NOT EXISTS \"idx_{t}_tenant\" ON \"{table_name}\" (\"tenant_id\") WHERE \"deleted_at\" IS NULL",
t = sanitize_identifier(&table_name)
);
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
tenant_idx_sql,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
// 为字段创建索引(使用参数化方式避免 SQL 注入)
for field in &entity.fields {
if field.unique || field.required {
let sanitized_field = sanitize_identifier(&field.name);
let idx_name = format!(
"idx_{}_{}_{}",
sanitize_identifier(&table_name),
sanitized_field,
if field.unique { "uniq" } else { "idx" }
);
let idx_sql = format!(
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" (\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
);
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
idx_sql,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
}
}
tracing::info!(table = %table_name, "Dynamic table created");
Ok(())
}
/// 删除动态表
pub async fn drop_table(
db: &DatabaseConnection,
plugin_id: &str,
entity_name: &str,
) -> PluginResult<()> {
let table_name = Self::table_name(plugin_id, entity_name);
let sql = format!("DROP TABLE IF EXISTS \"{}\"", table_name);
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
sql,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
tracing::info!(table = %table_name, "Dynamic table dropped");
Ok(())
}
/// 检查表是否存在
pub async fn table_exists(db: &DatabaseConnection, table_name: &str) -> PluginResult<bool> {
#[derive(FromQueryResult)]
struct ExistsResult {
exists: bool,
}
let result = ExistsResult::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)",
[table_name.into()],
))
.one(db)
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
Ok(result.map(|r| r.exists).unwrap_or(false))
}
/// 构建 INSERT SQL
pub fn build_insert_sql(
table_name: &str,
tenant_id: Uuid,
user_id: Uuid,
data: &serde_json::Value,
) -> (String, Vec<Value>) {
let id = Uuid::now_v7();
Self::build_insert_sql_with_id(table_name, id, tenant_id, user_id, data)
}
/// 构建 INSERT SQL指定 ID
pub fn build_insert_sql_with_id(
table_name: &str,
id: Uuid,
tenant_id: Uuid,
user_id: Uuid,
data: &serde_json::Value,
) -> (String, Vec<Value>) {
let sql = format!(
"INSERT INTO \"{}\" (id, tenant_id, data, created_by, updated_by, version) \
VALUES ($1, $2, $3, $4, $5, 1) \
RETURNING id, tenant_id, data, created_at, updated_at, version",
table_name
);
let values = vec![
id.into(),
tenant_id.into(),
serde_json::to_string(data).unwrap_or_default().into(),
user_id.into(),
user_id.into(),
];
(sql, values)
}
/// 构建 SELECT SQL
pub fn build_query_sql(
table_name: &str,
tenant_id: Uuid,
limit: u64,
offset: u64,
) -> (String, Vec<Value>) {
let sql = format!(
"SELECT id, data, created_at, updated_at, version \
FROM \"{}\" \
WHERE tenant_id = $1 AND deleted_at IS NULL \
ORDER BY created_at DESC \
LIMIT $2 OFFSET $3",
table_name
);
let values = vec![tenant_id.into(), (limit as i64).into(), (offset as i64).into()];
(sql, values)
}
/// 构建 COUNT SQL
pub fn build_count_sql(table_name: &str, tenant_id: Uuid) -> (String, Vec<Value>) {
let sql = format!(
"SELECT COUNT(*) as count FROM \"{}\" WHERE tenant_id = $1 AND deleted_at IS NULL",
table_name
);
let values = vec![tenant_id.into()];
(sql, values)
}
/// 构建 UPDATE SQL含乐观锁
pub fn build_update_sql(
table_name: &str,
id: Uuid,
tenant_id: Uuid,
user_id: Uuid,
data: &serde_json::Value,
version: i32,
) -> (String, Vec<Value>) {
let sql = format!(
"UPDATE \"{}\" \
SET data = $1, updated_at = NOW(), updated_by = $2, version = version + 1 \
WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \
RETURNING id, data, created_at, updated_at, version",
table_name
);
let values = vec![
serde_json::to_string(data).unwrap_or_default().into(),
user_id.into(),
id.into(),
tenant_id.into(),
version.into(),
];
(sql, values)
}
/// 构建 DELETE SQL软删除
pub fn build_delete_sql(
table_name: &str,
id: Uuid,
tenant_id: Uuid,
) -> (String, Vec<Value>) {
let sql = format!(
"UPDATE \"{}\" \
SET deleted_at = NOW(), updated_at = NOW() \
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
table_name
);
let values = vec![id.into(), tenant_id.into()];
(sql, values)
}
/// 构建单条查询 SQL
pub fn build_get_by_id_sql(
table_name: &str,
id: Uuid,
tenant_id: Uuid,
) -> (String, Vec<Value>) {
let sql = format!(
"SELECT id, data, created_at, updated_at, version \
FROM \"{}\" \
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
table_name
);
let values = vec![id.into(), tenant_id.into()];
(sql, values)
}
}

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

View File

@@ -0,0 +1,3 @@
pub mod plugin;
pub mod plugin_entity;
pub mod plugin_event_subscription;

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

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

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

View 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>;

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

View File

@@ -0,0 +1,2 @@
pub mod data_handler;
pub mod plugin_handler;

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

View 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(&current_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))
}
}

View 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;

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

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

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

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