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:
@@ -302,6 +302,21 @@ const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[
|
||||
"create",
|
||||
"创建消息模板",
|
||||
),
|
||||
// === Plugin module ===
|
||||
(
|
||||
"plugin.admin",
|
||||
"插件管理",
|
||||
"plugin",
|
||||
"admin",
|
||||
"管理插件全生命周期",
|
||||
),
|
||||
(
|
||||
"plugin.list",
|
||||
"查看插件",
|
||||
"plugin",
|
||||
"list",
|
||||
"查看插件列表",
|
||||
),
|
||||
];
|
||||
|
||||
/// Indices of read-only (list/read) permissions within DEFAULT_PERMISSIONS.
|
||||
@@ -324,6 +339,7 @@ const READ_PERM_INDICES: &[usize] = &[
|
||||
44, // workflow.read
|
||||
49, // message.list
|
||||
51, // message.template.list
|
||||
53, // plugin.list
|
||||
];
|
||||
|
||||
/// Seed default auth data for a new tenant.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tracing::{error, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -31,6 +31,32 @@ impl DomainEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// 过滤事件接收器 — 只接收匹配 `event_type_prefix` 的事件
|
||||
pub struct FilteredEventReceiver {
|
||||
receiver: mpsc::Receiver<DomainEvent>,
|
||||
}
|
||||
|
||||
impl FilteredEventReceiver {
|
||||
/// 接收下一个匹配的事件
|
||||
pub async fn recv(&mut self) -> Option<DomainEvent> {
|
||||
self.receiver.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
/// 订阅句柄 — 用于取消过滤订阅
|
||||
pub struct SubscriptionHandle {
|
||||
cancel_tx: mpsc::Sender<()>,
|
||||
join_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl SubscriptionHandle {
|
||||
/// 取消订阅并等待后台任务结束
|
||||
pub async fn cancel(self) {
|
||||
let _ = self.cancel_tx.send(()).await;
|
||||
let _ = self.join_handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 进程内事件总线
|
||||
#[derive(Clone)]
|
||||
pub struct EventBus {
|
||||
@@ -84,4 +110,57 @@ impl EventBus {
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<DomainEvent> {
|
||||
self.sender.subscribe()
|
||||
}
|
||||
|
||||
/// 按事件类型前缀过滤订阅。
|
||||
///
|
||||
/// 为每次调用 spawn 一个 Tokio task 从 broadcast channel 读取,
|
||||
/// 只转发匹配 `event_type_prefix` 的事件到 mpsc channel(capacity 256)。
|
||||
pub fn subscribe_filtered(
|
||||
&self,
|
||||
event_type_prefix: String,
|
||||
) -> (FilteredEventReceiver, SubscriptionHandle) {
|
||||
let mut broadcast_rx = self.sender.subscribe();
|
||||
let (mpsc_tx, mpsc_rx) = mpsc::channel(256);
|
||||
let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
let prefix = event_type_prefix.clone();
|
||||
let join_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_rx.recv() => {
|
||||
tracing::info!(prefix = %prefix, "Filtered subscription cancelled");
|
||||
break;
|
||||
}
|
||||
event = broadcast_rx.recv() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if event.event_type.starts_with(&prefix) {
|
||||
if mpsc_tx.send(event).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(prefix = %prefix, lagged = n, "Filtered subscriber lagged");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!(prefix = %event_type_prefix, "Filtered subscription created");
|
||||
|
||||
(
|
||||
FilteredEventReceiver { receiver: mpsc_rx },
|
||||
SubscriptionHandle {
|
||||
cancel_tx,
|
||||
join_handle,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,6 @@ pub mod events;
|
||||
pub mod module;
|
||||
pub mod rbac;
|
||||
pub mod types;
|
||||
|
||||
// 便捷导出
|
||||
pub use module::{ModuleContext, ModuleType};
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppResult;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::events::EventBus;
|
||||
|
||||
/// 模块类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModuleType {
|
||||
/// 内置模块(编译时链接)
|
||||
Builtin,
|
||||
/// 插件模块(运行时加载)
|
||||
Plugin,
|
||||
}
|
||||
|
||||
/// 模块启动上下文 — 在 on_startup 时提供给模块
|
||||
pub struct ModuleContext {
|
||||
pub db: sea_orm::DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
}
|
||||
|
||||
/// 模块注册接口
|
||||
/// 所有业务模块(Auth, Workflow, Message, Config, 行业模块)都实现此 trait
|
||||
#[async_trait::async_trait]
|
||||
@@ -13,11 +29,21 @@ pub trait ErpModule: Send + Sync {
|
||||
/// 模块名称(唯一标识)
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 模块唯一 ID(默认等于 name)
|
||||
fn id(&self) -> &str {
|
||||
self.name()
|
||||
}
|
||||
|
||||
/// 模块版本
|
||||
fn version(&self) -> &str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
/// 模块类型
|
||||
fn module_type(&self) -> ModuleType {
|
||||
ModuleType::Builtin
|
||||
}
|
||||
|
||||
/// 依赖的其他模块名称
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
vec![]
|
||||
@@ -26,6 +52,21 @@ pub trait ErpModule: Send + Sync {
|
||||
/// 注册事件处理器
|
||||
fn register_event_handlers(&self, _bus: &EventBus) {}
|
||||
|
||||
/// 模块启动钩子 — 服务启动时调用
|
||||
async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 模块关闭钩子 — 服务关闭时调用
|
||||
async fn on_shutdown(&self) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 健康检查
|
||||
async fn health_check(&self) -> AppResult<serde_json::Value> {
|
||||
Ok(serde_json::json!({"status": "healthy"}))
|
||||
}
|
||||
|
||||
/// 租户创建时的初始化钩子。
|
||||
///
|
||||
/// 用于为新建租户创建默认角色、管理员用户等初始数据。
|
||||
@@ -72,7 +113,9 @@ impl ModuleRegistry {
|
||||
pub fn register(mut self, module: impl ErpModule + 'static) -> Self {
|
||||
tracing::info!(
|
||||
module = module.name(),
|
||||
id = module.id(),
|
||||
version = module.version(),
|
||||
module_type = ?module.module_type(),
|
||||
"Module registered"
|
||||
);
|
||||
let mut modules = (*self.modules).clone();
|
||||
@@ -90,4 +133,202 @@ impl ModuleRegistry {
|
||||
pub fn modules(&self) -> &[Arc<dyn ErpModule>] {
|
||||
&self.modules
|
||||
}
|
||||
|
||||
/// 按名称获取模块
|
||||
pub fn get_module(&self, name: &str) -> Option<Arc<dyn ErpModule>> {
|
||||
self.modules.iter().find(|m| m.name() == name).cloned()
|
||||
}
|
||||
|
||||
/// 按拓扑排序返回模块(依赖在前,被依赖在后)
|
||||
///
|
||||
/// 使用 Kahn 算法,环检测返回 Validation 错误。
|
||||
pub fn sorted_modules(&self) -> AppResult<Vec<Arc<dyn ErpModule>>> {
|
||||
let modules = &*self.modules;
|
||||
let n = modules.len();
|
||||
if n == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// 构建名称到索引的映射
|
||||
let name_to_idx: HashMap<&str, usize> = modules
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (m.name(), i))
|
||||
.collect();
|
||||
|
||||
// 构建邻接表和入度
|
||||
let mut adjacency: Vec<Vec<usize>> = vec![vec![]; n];
|
||||
let mut in_degree: Vec<usize> = vec![0; n];
|
||||
|
||||
for (idx, module) in modules.iter().enumerate() {
|
||||
for dep in module.dependencies() {
|
||||
if let Some(&dep_idx) = name_to_idx.get(dep) {
|
||||
adjacency[dep_idx].push(idx);
|
||||
in_degree[idx] += 1;
|
||||
}
|
||||
// 依赖未注册的模块不阻断(可能是可选依赖)
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn 算法
|
||||
let mut queue: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
|
||||
let mut sorted_indices = Vec::with_capacity(n);
|
||||
|
||||
while let Some(idx) = queue.pop() {
|
||||
sorted_indices.push(idx);
|
||||
for &next in &adjacency[idx] {
|
||||
in_degree[next] -= 1;
|
||||
if in_degree[next] == 0 {
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sorted_indices.len() != n {
|
||||
let cycle_modules: Vec<&str> = (0..n)
|
||||
.filter(|i| !sorted_indices.contains(i))
|
||||
.filter_map(|i| modules.get(i).map(|m| m.name()))
|
||||
.collect();
|
||||
return Err(AppError::Validation(format!(
|
||||
"模块依赖存在循环: {}",
|
||||
cycle_modules.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(sorted_indices
|
||||
.into_iter()
|
||||
.map(|i| modules[i].clone())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 按拓扑顺序启动所有模块
|
||||
pub async fn startup_all(&self, ctx: &ModuleContext) -> AppResult<()> {
|
||||
let sorted = self.sorted_modules()?;
|
||||
for module in sorted {
|
||||
tracing::info!(module = module.name(), "Starting module");
|
||||
module.on_startup(ctx).await?;
|
||||
tracing::info!(module = module.name(), "Module started");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按拓扑逆序关闭所有模块
|
||||
pub async fn shutdown_all(&self) -> AppResult<()> {
|
||||
let sorted = self.sorted_modules()?;
|
||||
for module in sorted.into_iter().rev() {
|
||||
tracing::info!(module = module.name(), "Shutting down module");
|
||||
if let Err(e) = module.on_shutdown().await {
|
||||
tracing::error!(module = module.name(), error = %e, "Module shutdown failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 对所有模块执行健康检查
|
||||
pub async fn health_check_all(&self) -> Vec<(String, AppResult<serde_json::Value>)> {
|
||||
let mut results = Vec::with_capacity(self.modules.len());
|
||||
for module in self.modules.iter() {
|
||||
let result = module.health_check().await;
|
||||
results.push((module.name().to_string(), result));
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestModule {
|
||||
name: &'static str,
|
||||
deps: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ErpModule for TestModule {
|
||||
fn name(&self) -> &str {
|
||||
self.name
|
||||
}
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
self.deps.clone()
|
||||
}
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_modules_empty() {
|
||||
let registry = ModuleRegistry::new();
|
||||
let sorted = registry.sorted_modules().unwrap();
|
||||
assert!(sorted.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_modules_no_deps() {
|
||||
let registry = ModuleRegistry::new()
|
||||
.register(TestModule {
|
||||
name: "a",
|
||||
deps: vec![],
|
||||
})
|
||||
.register(TestModule {
|
||||
name: "b",
|
||||
deps: vec![],
|
||||
});
|
||||
let sorted = registry.sorted_modules().unwrap();
|
||||
assert_eq!(sorted.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_modules_with_deps() {
|
||||
let registry = ModuleRegistry::new()
|
||||
.register(TestModule {
|
||||
name: "auth",
|
||||
deps: vec![],
|
||||
})
|
||||
.register(TestModule {
|
||||
name: "plugin",
|
||||
deps: vec!["auth", "config"],
|
||||
})
|
||||
.register(TestModule {
|
||||
name: "config",
|
||||
deps: vec!["auth"],
|
||||
});
|
||||
let sorted = registry.sorted_modules().unwrap();
|
||||
let names: Vec<&str> = sorted.iter().map(|m| m.name()).collect();
|
||||
let auth_pos = names.iter().position(|&n| n == "auth").unwrap();
|
||||
let config_pos = names.iter().position(|&n| n == "config").unwrap();
|
||||
let plugin_pos = names.iter().position(|&n| n == "plugin").unwrap();
|
||||
assert!(auth_pos < config_pos);
|
||||
assert!(config_pos < plugin_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_modules_circular_dep() {
|
||||
let registry = ModuleRegistry::new()
|
||||
.register(TestModule {
|
||||
name: "a",
|
||||
deps: vec!["b"],
|
||||
})
|
||||
.register(TestModule {
|
||||
name: "b",
|
||||
deps: vec!["a"],
|
||||
});
|
||||
let result = registry.sorted_modules();
|
||||
assert!(result.is_err());
|
||||
match result.err().unwrap() {
|
||||
AppError::Validation(msg) => assert!(msg.contains("循环")),
|
||||
other => panic!("Expected Validation, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_module_found() {
|
||||
let registry = ModuleRegistry::new().register(TestModule {
|
||||
name: "auth",
|
||||
deps: vec![],
|
||||
});
|
||||
assert!(registry.get_module("auth").is_some());
|
||||
assert!(registry.get_module("unknown").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
24
crates/erp-plugin/Cargo.toml
Normal file
24
crates/erp-plugin/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "erp-plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "ERP WASM 插件运行时 — 生产级 Host API"
|
||||
|
||||
[dependencies]
|
||||
wasmtime = "43"
|
||||
wasmtime-wasi = "43"
|
||||
erp-core = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
dashmap = "6"
|
||||
toml = "0.8"
|
||||
axum = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
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,
|
||||
}
|
||||
48
crates/erp-plugin/wit/plugin.wit
Normal file
48
crates/erp-plugin/wit/plugin.wit
Normal file
@@ -0,0 +1,48 @@
|
||||
package erp:plugin;
|
||||
|
||||
/// 宿主暴露给插件的 API(插件 import 这些函数)
|
||||
interface host-api {
|
||||
/// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段)
|
||||
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤)
|
||||
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 更新记录(自动检查 version 乐观锁)
|
||||
db-update: func(entity: string, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
|
||||
|
||||
/// 软删除记录
|
||||
db-delete: func(entity: string, id: string) -> result<_, string>;
|
||||
|
||||
/// 发布领域事件
|
||||
event-publish: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
|
||||
/// 读取系统配置
|
||||
config-get: func(key: string) -> result<list<u8>, string>;
|
||||
|
||||
/// 写日志(自动关联 tenant_id + plugin_id)
|
||||
log-write: func(level: string, message: string);
|
||||
|
||||
/// 获取当前用户信息
|
||||
current-user: func() -> result<list<u8>, string>;
|
||||
|
||||
/// 检查当前用户权限
|
||||
check-permission: func(permission: string) -> result<bool, string>;
|
||||
}
|
||||
|
||||
/// 插件导出的 API(宿主调用这些函数)
|
||||
interface plugin-api {
|
||||
/// 插件初始化(加载时调用一次)
|
||||
init: func() -> result<_, string>;
|
||||
|
||||
/// 租户创建时调用
|
||||
on-tenant-created: func(tenant-id: string) -> result<_, string>;
|
||||
|
||||
/// 处理订阅的事件
|
||||
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
}
|
||||
|
||||
world plugin-world {
|
||||
import host-api;
|
||||
export plugin-api;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ erp-auth.workspace = true
|
||||
erp-config.workspace = true
|
||||
erp-workflow.workspace = true
|
||||
erp-message.workspace = true
|
||||
erp-plugin.workspace = true
|
||||
anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -32,6 +32,8 @@ mod m20260414_000029_add_standard_fields_to_process_variables;
|
||||
mod m20260414_000032_fix_settings_unique_index_null;
|
||||
mod m20260415_000030_add_version_to_message_tables;
|
||||
mod m20260416_000031_create_domain_events;
|
||||
mod m20260417_000033_create_plugins;
|
||||
mod m20260417_000034_seed_plugin_permissions;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -71,6 +73,8 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260415_000030_add_version_to_message_tables::Migration),
|
||||
Box::new(m20260416_000031_create_domain_events::Migration),
|
||||
Box::new(m20260414_000032_fix_settings_unique_index_null::Migration),
|
||||
Box::new(m20260417_000033_create_plugins::Migration),
|
||||
Box::new(m20260417_000034_seed_plugin_permissions::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ pub struct Migration;
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 删除旧索引
|
||||
// 1. 删除旧索引
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
@@ -20,13 +20,7 @@ impl MigrationTrait for Migration {
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 创建新索引,使用 COALESCE 处理 NULL scope_id
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'), setting_key) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 清理可能已存在的重复数据(保留每组最新的一条)
|
||||
// 2. 先清理可能已存在的重复数据(保留每组最新的一条)
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
@@ -41,6 +35,12 @@ impl MigrationTrait for Migration {
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 3. 创建新索引,使用 COALESCE 处理 NULL scope_id
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'), setting_key) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 1. plugins 表 — 插件注册与生命周期
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugins"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("name")).string_len(200).not_null())
|
||||
.col(ColumnDef::new(Alias::new("plugin_version")).string_len(50).not_null())
|
||||
.col(ColumnDef::new(Alias::new("description")).text().null())
|
||||
.col(ColumnDef::new(Alias::new("author")).string_len(200).null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("status"))
|
||||
.string_len(20)
|
||||
.not_null()
|
||||
.default("uploaded"),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("manifest_json")).json().not_null())
|
||||
.col(ColumnDef::new(Alias::new("wasm_binary")).binary().not_null())
|
||||
.col(ColumnDef::new(Alias::new("wasm_hash")).string_len(64).not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("config_json"))
|
||||
.json()
|
||||
.not_null()
|
||||
.default(Expr::val("{}")),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("error_message")).text().null())
|
||||
.col(ColumnDef::new(Alias::new("installed_at")).timestamp_with_time_zone().null())
|
||||
.col(ColumnDef::new(Alias::new("enabled_at")).timestamp_with_time_zone().null())
|
||||
// 标准字段
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_plugins_tenant_status")
|
||||
.table(Alias::new("plugins"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.col(Alias::new("status"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_plugins_name")
|
||||
.table(Alias::new("plugins"))
|
||||
.col(Alias::new("name"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. plugin_entities 表 — 插件动态表注册
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugin_entities"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("plugin_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("entity_name")).string_len(100).not_null())
|
||||
.col(ColumnDef::new(Alias::new("table_name")).string_len(200).not_null())
|
||||
.col(ColumnDef::new(Alias::new("schema_json")).json().not_null())
|
||||
// 标准字段
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_plugin_entities_plugin")
|
||||
.table(Alias::new("plugin_entities"))
|
||||
.col(Alias::new("plugin_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. plugin_event_subscriptions 表 — 事件订阅
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugin_event_subscriptions"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("plugin_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("event_pattern")).string_len(200).not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_plugin_event_subs_plugin")
|
||||
.table(Alias::new("plugin_event_subscriptions"))
|
||||
.col(Alias::new("plugin_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("plugin_event_subscriptions")).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("plugin_entities")).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("plugins")).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 为已存在的租户补充 plugin 模块权限,并分配给 admin 角色。
|
||||
/// seed_tenant_auth 只在租户创建时执行,已存在的租户缺少 plugin 相关权限。
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 插入 plugin 权限(如果不存在)
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT gen_random_uuid(), t.id, 'plugin.admin', '插件管理', 'plugin', 'admin', '管理插件全生命周期', NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1
|
||||
FROM tenant t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM permissions p WHERE p.code = 'plugin.admin' AND p.tenant_id = t.id AND p.deleted_at IS NULL
|
||||
)
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT gen_random_uuid(), t.id, 'plugin.list', '查看插件', 'plugin', 'list', '查看插件列表', NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1
|
||||
FROM tenant t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM permissions p WHERE p.code = 'plugin.list' AND p.tenant_id = t.id AND p.deleted_at IS NULL
|
||||
)
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 将 plugin 权限分配给 admin 角色(如果尚未分配)
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT r.id, p.id, r.tenant_id, NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ('plugin.admin', 'plugin.list') AND p.deleted_at IS NULL
|
||||
WHERE r.code = 'admin' AND r.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted_at IS NULL
|
||||
)
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 删除 plugin 权限的角色关联
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE code IN ('plugin.admin', 'plugin.list')
|
||||
)
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 删除 plugin 权限
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"DELETE FROM permissions WHERE code IN ('plugin.admin', 'plugin.list')".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ use tracing_subscriber::EnvFilter;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::{ErpModule, ModuleRegistry};
|
||||
use erp_core::module::{ErpModule, ModuleContext, ModuleRegistry};
|
||||
use erp_server_migration::MigratorTrait;
|
||||
use sea_orm::{ConnectionTrait, FromQueryResult};
|
||||
|
||||
@@ -310,9 +310,40 @@ async fn main() -> anyhow::Result<()> {
|
||||
"Modules registered"
|
||||
);
|
||||
|
||||
// Initialize plugin engine
|
||||
let plugin_config = erp_plugin::engine::PluginEngineConfig::default();
|
||||
let plugin_engine = erp_plugin::engine::PluginEngine::new(
|
||||
db.clone(),
|
||||
event_bus.clone(),
|
||||
plugin_config,
|
||||
)?;
|
||||
tracing::info!("Plugin engine initialized");
|
||||
|
||||
// Register plugin module
|
||||
let plugin_module = erp_plugin::module::PluginModule;
|
||||
let registry = registry.register(plugin_module);
|
||||
|
||||
// Register event handlers
|
||||
registry.register_handlers(&event_bus);
|
||||
|
||||
// Startup all modules (按拓扑顺序调用 on_startup)
|
||||
let module_ctx = ModuleContext {
|
||||
db: db.clone(),
|
||||
event_bus: event_bus.clone(),
|
||||
};
|
||||
registry.startup_all(&module_ctx).await?;
|
||||
tracing::info!("All modules started");
|
||||
|
||||
// 恢复运行中的插件(服务器重启后自动重新加载)
|
||||
match plugin_engine.recover_plugins(&db).await {
|
||||
Ok(recovered) => {
|
||||
tracing::info!(count = recovered.len(), "Plugins recovered");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to recover plugins");
|
||||
}
|
||||
}
|
||||
|
||||
// Start message event listener (workflow events → message notifications)
|
||||
erp_message::MessageModule::start_event_listener(db.clone(), event_bus.clone());
|
||||
tracing::info!("Message event listener started");
|
||||
@@ -339,6 +370,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
module_registry: registry,
|
||||
redis: redis_client.clone(),
|
||||
default_tenant_id,
|
||||
plugin_engine,
|
||||
};
|
||||
|
||||
// --- Build the router ---
|
||||
@@ -370,6 +402,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.merge(erp_config::ConfigModule::protected_routes())
|
||||
.merge(erp_workflow::WorkflowModule::protected_routes())
|
||||
.merge(erp_message::MessageModule::protected_routes())
|
||||
.merge(erp_plugin::module::PluginModule::protected_routes())
|
||||
.merge(handlers::audit_log::audit_log_router())
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
@@ -397,6 +430,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
// 优雅关闭所有模块(按拓扑逆序)
|
||||
state.module_registry.shutdown_all().await?;
|
||||
tracing::info!("Server shutdown complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ pub struct AppState {
|
||||
pub redis: redis::Client,
|
||||
/// 实际的默认租户 ID,从数据库种子数据中获取。
|
||||
pub default_tenant_id: uuid::Uuid,
|
||||
/// 插件引擎
|
||||
pub plugin_engine: erp_plugin::engine::PluginEngine,
|
||||
}
|
||||
|
||||
/// Allow handlers to extract `DatabaseConnection` directly from `State<AppState>`.
|
||||
@@ -80,3 +82,14 @@ impl FromRef<AppState> for erp_message::MessageState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow erp-plugin handlers to extract their required state.
|
||||
impl FromRef<AppState> for erp_plugin::state::PluginState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
Self {
|
||||
db: state.db.clone(),
|
||||
event_bus: state.event_bus.clone(),
|
||||
engine: state.plugin_engine.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user