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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user