P2 平台通用服务: - manifest 扩展: settings/numbering/templates/trigger_events/importable/exportable 声明 - 插件配置 UI: PluginSettingsForm 自动表单 + 后端校验 + 详情抽屉 Settings 标签页 - 编号规则: Host API numbering-generate + PostgreSQL 序列 + manifest 绑定 - 触发事件: data_service create/update/delete 自动发布 DomainEvent - WIT 接口: 新增 numbering-generate/setting-get Host API P3 质量保障: - plugin_validator.rs: 安全扫描(WASM大小/实体数量/字段校验) + 复杂度评分 - 运行时监控指标: RuntimeMetrics (错误率/响应时间/Fuel/内存) - 性能基准: BenchmarkResult 阈值定义 - 上传时自动安全扫描 + /validate API 端点 P4 插件市场: - 数据库迁移: plugin_market_entries + plugin_market_reviews 表 - 前端 PluginMarket 页面: 分类浏览/搜索/详情/评分 - 路由注册: /plugins/market 测试: 269 全通过 (71 erp-plugin + 41 auth + 57 config + 34 core + 50 message + 16 workflow)
1117 lines
40 KiB
Rust
1117 lines
40 KiB
Rust
use chrono::Utc;
|
||
use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||
use std::collections::HashMap;
|
||
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)?;
|
||
|
||
// 安全扫描
|
||
let validation = crate::plugin_validator::validate_plugin_security(&manifest, wasm_binary.len())?;
|
||
if !validation.valid {
|
||
return Err(PluginError::ValidationError(format!(
|
||
"插件安全校验失败: {}", validation.errors.join("; ")
|
||
)).into());
|
||
}
|
||
|
||
// 计算 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 (i, entity_def) in schema.entities.iter().enumerate() {
|
||
let table_name = DynamicTableManager::table_name(&manifest.metadata.id, &entity_def.name);
|
||
tracing::info!(step = i, entity = %entity_def.name, table = %table_name, "Creating dynamic table");
|
||
|
||
// 创建动态表
|
||
DynamicTableManager::create_table(db, &manifest.metadata.id, entity_def).await
|
||
.map_err(|e| {
|
||
tracing::error!(entity = %entity_def.name, table = %table_name, error = %e, "Failed to create dynamic table");
|
||
e
|
||
})?;
|
||
|
||
// 注册 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()),
|
||
manifest_id: Set(manifest.metadata.id.clone()),
|
||
is_public: Set(entity_def.is_public.unwrap_or(false)),
|
||
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?;
|
||
}
|
||
}
|
||
|
||
// 注册插件声明的权限到 permissions 表
|
||
tracing::info!("Registering plugin permissions");
|
||
if let Some(perms) = &manifest.permissions {
|
||
register_plugin_permissions(
|
||
db,
|
||
tenant_id,
|
||
operator_id,
|
||
&manifest.metadata.id,
|
||
perms,
|
||
&now,
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Failed to register permissions");
|
||
e
|
||
})?;
|
||
}
|
||
|
||
// 将插件权限自动分配给 admin 角色
|
||
tracing::info!("Granting plugin permissions to admin role");
|
||
grant_permissions_to_admin(db, tenant_id, &manifest.metadata.id).await?;
|
||
|
||
// 加载到内存
|
||
tracing::info!(manifest_id = %manifest.metadata.id, "Loading plugin into engine");
|
||
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;
|
||
|
||
// 确保插件权限已分配给 admin 角色(幂等操作)
|
||
grant_permissions_to_admin(db, tenant_id, plugin_manifest_id).await?;
|
||
|
||
// 如果之前是 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?;
|
||
}
|
||
|
||
// 初始化(非致命:WASM 插件可能不包含 initialize 逻辑,失败不阻塞启用)
|
||
let init_error = match engine.initialize(plugin_manifest_id).await {
|
||
Ok(()) => None,
|
||
Err(e) => {
|
||
tracing::warn!(plugin = %plugin_manifest_id, error = %e, "插件初始化失败(非致命,继续启用)");
|
||
Some(format!("初始化警告: {}", e))
|
||
}
|
||
};
|
||
|
||
// 启动事件监听(非致命)
|
||
if let Err(e) = engine.start_event_listener(plugin_manifest_id).await {
|
||
tracing::warn!(plugin = %plugin_manifest_id, error = %e, "事件监听启动失败(非致命,继续启用)");
|
||
}
|
||
|
||
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(init_error);
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 清理此插件注册的权限
|
||
unregister_plugin_permissions(db, tenant_id, &manifest.metadata.id).await?;
|
||
|
||
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());
|
||
|
||
// 批量查询所有插件的 entities(N+1 → 2 固定查询)
|
||
let plugin_ids: Vec<Uuid> = models.iter().map(|m| m.id).collect();
|
||
let entities_map = find_batch_plugin_entities(&plugin_ids, tenant_id, db).await;
|
||
|
||
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,
|
||
settings: None,
|
||
numbering: None,
|
||
templates: None,
|
||
trigger_events: None,
|
||
}
|
||
});
|
||
let entities = entities_map.get(&model.id).cloned().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)?;
|
||
|
||
// 校验配置值是否符合 manifest settings 声明
|
||
let manifest: PluginManifest =
|
||
serde_json::from_value(model.manifest_json.clone())
|
||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||
if let Some(settings) = &manifest.settings {
|
||
validate_plugin_settings(config.as_object().ok_or_else(|| {
|
||
PluginError::ValidationError("config 必须是 JSON 对象".to_string())
|
||
})?, &settings.fields)?;
|
||
}
|
||
|
||
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 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()))?;
|
||
|
||
// 构建 schema 响应:entities + ui 页面配置 + settings + numbering + trigger_events
|
||
let mut result = serde_json::Map::new();
|
||
if let Some(schema) = &manifest.schema {
|
||
result.insert(
|
||
"entities".to_string(),
|
||
serde_json::to_value(&schema.entities).unwrap_or_default(),
|
||
);
|
||
}
|
||
if let Some(ui) = &manifest.ui {
|
||
result.insert(
|
||
"ui".to_string(),
|
||
serde_json::to_value(ui).unwrap_or_default(),
|
||
);
|
||
}
|
||
if let Some(settings) = &manifest.settings {
|
||
result.insert(
|
||
"settings".to_string(),
|
||
serde_json::to_value(settings).unwrap_or_default(),
|
||
);
|
||
}
|
||
if let Some(numbering) = &manifest.numbering {
|
||
result.insert(
|
||
"numbering".to_string(),
|
||
serde_json::to_value(numbering).unwrap_or_default(),
|
||
);
|
||
}
|
||
if let Some(triggers) = &manifest.trigger_events {
|
||
result.insert(
|
||
"trigger_events".to_string(),
|
||
serde_json::to_value(triggers).unwrap_or_default(),
|
||
);
|
||
}
|
||
Ok(serde_json::Value::Object(result))
|
||
}
|
||
|
||
/// 清除插件记录(软删除,仅限已卸载状态)
|
||
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_any(&model.status, &["uninstalled", "uploaded"])?;
|
||
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(())
|
||
}
|
||
|
||
/// 热更新插件 — 上传新版本 WASM + manifest,对比 schema 变更,执行增量 DDL
|
||
///
|
||
/// 流程:
|
||
/// 1. 解析新 manifest
|
||
/// 2. 获取当前插件信息
|
||
/// 3. 对比 schema 变更,为新增实体创建表
|
||
/// 4. 卸载旧 WASM,加载新 WASM
|
||
/// 5. 更新数据库记录
|
||
/// 6. 失败时保持旧版本继续运行(回滚)
|
||
pub async fn upgrade(
|
||
plugin_id: Uuid,
|
||
tenant_id: Uuid,
|
||
operator_id: Uuid,
|
||
new_wasm: Vec<u8>,
|
||
new_manifest_toml: &str,
|
||
db: &sea_orm::DatabaseConnection,
|
||
engine: &PluginEngine,
|
||
) -> AppResult<PluginResp> {
|
||
let new_manifest = parse_manifest(new_manifest_toml)?;
|
||
|
||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||
let old_manifest: PluginManifest =
|
||
serde_json::from_value(model.manifest_json.clone())
|
||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||
|
||
let old_version = old_manifest.metadata.version.clone();
|
||
let new_version = new_manifest.metadata.version.clone();
|
||
|
||
if old_manifest.metadata.id != new_manifest.metadata.id {
|
||
return Err(PluginError::InvalidManifest(
|
||
format!("插件 ID 不匹配: 旧={}, 新={}", old_manifest.metadata.id, new_manifest.metadata.id)
|
||
).into());
|
||
}
|
||
|
||
let plugin_manifest_id = &new_manifest.metadata.id;
|
||
|
||
// 对比 schema — 为新增实体创建动态表 + 已有实体字段演进
|
||
if let Some(new_schema) = &new_manifest.schema {
|
||
let old_schema = old_manifest.schema.as_ref();
|
||
|
||
for entity in &new_schema.entities {
|
||
let old_entity = old_schema
|
||
.and_then(|s| s.entities.iter().find(|e| e.name == entity.name));
|
||
|
||
match old_entity {
|
||
None => {
|
||
tracing::info!(entity = %entity.name, "创建新增实体表");
|
||
DynamicTableManager::create_table(db, plugin_manifest_id, entity).await?;
|
||
}
|
||
Some(old) => {
|
||
let diff = DynamicTableManager::diff_entity_fields(old, entity);
|
||
if !diff.new_filterable.is_empty() || !diff.new_searchable.is_empty() {
|
||
tracing::info!(
|
||
entity = %entity.name,
|
||
new_cols = diff.new_filterable.len(),
|
||
new_search = diff.new_searchable.len(),
|
||
"Schema 演进:新增 Generated Column"
|
||
);
|
||
DynamicTableManager::alter_add_generated_columns(
|
||
db, plugin_manifest_id, entity, &diff
|
||
).await?;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 先加载新版本到临时 key,确保成功后再替换旧版本(原子回滚)
|
||
let temp_id = format!("{}__upgrade_{}", plugin_manifest_id, Uuid::now_v7());
|
||
engine
|
||
.load(&temp_id, &new_wasm, new_manifest.clone())
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "新版本 WASM 加载失败,旧版本仍在运行");
|
||
e
|
||
})?;
|
||
|
||
// 新版本加载成功,卸载旧版本并重命名新版本为正式 key
|
||
engine.unload(plugin_manifest_id).await.ok();
|
||
engine.rename_plugin(&temp_id, plugin_manifest_id).await?;
|
||
|
||
// 更新数据库记录
|
||
let wasm_hash = {
|
||
let mut hasher = Sha256::new();
|
||
hasher.update(&new_wasm);
|
||
format!("{:x}", hasher.finalize())
|
||
};
|
||
|
||
let now = Utc::now();
|
||
let mut active: plugin::ActiveModel = model.into();
|
||
active.wasm_binary = Set(new_wasm);
|
||
active.wasm_hash = Set(wasm_hash);
|
||
active.manifest_json = Set(serde_json::to_value(&new_manifest)
|
||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?);
|
||
active.plugin_version = Set(new_version.clone());
|
||
active.updated_at = Set(now);
|
||
active.updated_by = Set(Some(operator_id));
|
||
active.version = Set(active.version.unwrap() + 1);
|
||
|
||
let updated = active
|
||
.update(db)
|
||
.await
|
||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||
|
||
// 更新 plugin_entities 表中的 schema_json
|
||
if let Some(schema) = &new_manifest.schema {
|
||
for entity in &schema.entities {
|
||
let entity_model = 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
|
||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||
|
||
if let Some(em) = entity_model {
|
||
let mut active: plugin_entity::ActiveModel = em.into();
|
||
active.schema_json = Set(serde_json::to_value(entity)
|
||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?);
|
||
active.updated_at = Set(now);
|
||
active.updated_by = Set(Some(operator_id));
|
||
active.update(db).await.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||
}
|
||
}
|
||
}
|
||
|
||
tracing::info!(
|
||
plugin_id = %plugin_id,
|
||
old_version = %old_version,
|
||
new_version = %new_version,
|
||
"插件热更新成功"
|
||
);
|
||
|
||
let entities = find_plugin_entities(plugin_id, tenant_id, db).await?;
|
||
Ok(plugin_model_to_resp(&updated, &new_manifest, entities))
|
||
}
|
||
}
|
||
|
||
// ---- 内部辅助 ----
|
||
|
||
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))
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 公开的插件查询 — 供 handler 使用
|
||
pub async fn find_plugin_model(
|
||
plugin_id: Uuid,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> AppResult<plugin::Model> {
|
||
find_plugin(plugin_id, tenant_id, db).await
|
||
}
|
||
|
||
/// 批量查询多插件的 entities,返回 plugin_id → Vec<PluginEntityResp> 映射。
|
||
async fn find_batch_plugin_entities(
|
||
plugin_ids: &[Uuid],
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> HashMap<Uuid, Vec<PluginEntityResp>> {
|
||
if plugin_ids.is_empty() {
|
||
return HashMap::new();
|
||
}
|
||
|
||
let entities = plugin_entity::Entity::find()
|
||
.filter(plugin_entity::Column::PluginId.is_in(plugin_ids.iter().copied()))
|
||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||
.all(db)
|
||
.await
|
||
.unwrap_or_default();
|
||
|
||
let mut result: HashMap<Uuid, Vec<PluginEntityResp>> = HashMap::new();
|
||
for e in entities {
|
||
result.entry(e.plugin_id).or_default().push(PluginEntityResp {
|
||
name: e.entity_name.clone(),
|
||
display_name: e.entity_name,
|
||
table_name: e.table_name,
|
||
});
|
||
}
|
||
result
|
||
}
|
||
|
||
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(())
|
||
}
|
||
|
||
/// 校验配置值是否符合 manifest settings 声明
|
||
fn validate_plugin_settings(
|
||
config: &serde_json::Map<String, serde_json::Value>,
|
||
fields: &[crate::manifest::PluginSettingField],
|
||
) -> AppResult<()> {
|
||
use crate::manifest::PluginSettingType;
|
||
|
||
for field in fields {
|
||
let value = config.get(&field.name);
|
||
|
||
// 必填校验
|
||
if field.required {
|
||
match value {
|
||
None | Some(serde_json::Value::Null) => {
|
||
return Err(PluginError::ValidationError(format!(
|
||
"配置项 '{}' ({}) 为必填",
|
||
field.name, field.display_name
|
||
))
|
||
.into());
|
||
}
|
||
Some(serde_json::Value::String(s)) if s.is_empty() => {
|
||
return Err(PluginError::ValidationError(format!(
|
||
"配置项 '{}' ({}) 不能为空",
|
||
field.name, field.display_name
|
||
))
|
||
.into());
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// 类型校验
|
||
if let Some(val) = value {
|
||
if !val.is_null() {
|
||
let type_ok = match field.field_type {
|
||
PluginSettingType::Text => val.is_string(),
|
||
PluginSettingType::Number => val.is_number(),
|
||
PluginSettingType::Boolean => val.is_boolean(),
|
||
PluginSettingType::Select => val.is_string(),
|
||
PluginSettingType::Multiselect => val.is_array(),
|
||
PluginSettingType::Color => val.is_string(),
|
||
PluginSettingType::Date => val.is_string(),
|
||
PluginSettingType::Datetime => val.is_string(),
|
||
PluginSettingType::Json => true,
|
||
};
|
||
if !type_ok {
|
||
return Err(PluginError::ValidationError(format!(
|
||
"配置项 '{}' 类型错误,期望 {:?}",
|
||
field.name, field.field_type
|
||
))
|
||
.into());
|
||
}
|
||
|
||
// 数值范围校验
|
||
if let Some((min, max)) = field.range {
|
||
if let Some(n) = val.as_f64() {
|
||
if n < min || n > max {
|
||
return Err(PluginError::ValidationError(format!(
|
||
"配置项 '{}' ({}) 的值 {} 超出范围 [{}, {}]",
|
||
field.name, field.display_name, n, min, max
|
||
))
|
||
.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,
|
||
}
|
||
}
|
||
|
||
/// 将插件声明的权限注册到 permissions 表。
|
||
///
|
||
/// 使用 raw SQL 避免依赖 erp-auth 的 entity 类型。
|
||
/// 权限码格式:`{plugin_manifest_id}.{code}`(如 `erp-crm.customer.list`)。
|
||
/// 使用 `ON CONFLICT DO NOTHING` 保证幂等。
|
||
async fn register_plugin_permissions(
|
||
db: &sea_orm::DatabaseConnection,
|
||
tenant_id: Uuid,
|
||
operator_id: Uuid,
|
||
plugin_manifest_id: &str,
|
||
perms: &[crate::manifest::PluginPermission],
|
||
now: &chrono::DateTime<chrono::Utc>,
|
||
) -> AppResult<()> {
|
||
for perm in perms {
|
||
let full_code = format!("{}.{}", plugin_manifest_id, perm.code);
|
||
let resource = plugin_manifest_id.to_string();
|
||
let action = perm.code.clone();
|
||
let description: Option<String> = if perm.description.is_empty() {
|
||
None
|
||
} else {
|
||
Some(perm.description.clone())
|
||
};
|
||
|
||
let sql = r#"
|
||
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $7, $8, $8, NULL, 1)
|
||
ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO NOTHING
|
||
"#;
|
||
|
||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
vec![
|
||
sea_orm::Value::from(tenant_id),
|
||
sea_orm::Value::from(full_code.clone()),
|
||
sea_orm::Value::from(perm.name.clone()),
|
||
sea_orm::Value::from(resource),
|
||
sea_orm::Value::from(action),
|
||
sea_orm::Value::from(description),
|
||
sea_orm::Value::from(now.clone()),
|
||
sea_orm::Value::from(operator_id),
|
||
],
|
||
))
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(
|
||
plugin = plugin_manifest_id,
|
||
permission = %full_code,
|
||
error = %e,
|
||
"注册插件权限失败"
|
||
);
|
||
PluginError::DatabaseError(format!("注册插件权限 {} 失败: {}", full_code, e))
|
||
})?;
|
||
}
|
||
|
||
tracing::info!(
|
||
plugin = plugin_manifest_id,
|
||
count = perms.len(),
|
||
tenant_id = %tenant_id,
|
||
"插件权限注册完成"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// 将插件的所有权限分配给 admin 角色。
|
||
///
|
||
/// 使用 raw SQL 按 manifest_id 前缀匹配权限,INSERT 到 role_permissions。
|
||
/// ON CONFLICT DO NOTHING 保证幂等。
|
||
pub async fn grant_permissions_to_admin(
|
||
db: &sea_orm::DatabaseConnection,
|
||
tenant_id: Uuid,
|
||
plugin_manifest_id: &str,
|
||
) -> AppResult<()> {
|
||
let prefix = format!("{}.%", plugin_manifest_id);
|
||
|
||
let sql = r#"
|
||
INSERT INTO role_permissions (tenant_id, role_id, permission_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||
SELECT
|
||
p.tenant_id,
|
||
r.id,
|
||
p.id,
|
||
'all',
|
||
NOW(), NOW(),
|
||
r.id, r.id,
|
||
NULL, 1
|
||
FROM permissions p
|
||
CROSS JOIN roles r
|
||
WHERE p.tenant_id = $1
|
||
AND r.tenant_id = $1
|
||
AND r.code = 'admin'
|
||
AND r.deleted_at IS NULL
|
||
AND p.code LIKE $2
|
||
AND p.deleted_at IS NULL
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM role_permissions rp
|
||
WHERE rp.permission_id = p.id
|
||
AND rp.role_id = r.id
|
||
AND rp.deleted_at IS NULL
|
||
)
|
||
ON CONFLICT (role_id, permission_id) DO NOTHING
|
||
"#;
|
||
|
||
let result = db
|
||
.execute(sea_orm::Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
vec![
|
||
sea_orm::Value::from(tenant_id),
|
||
sea_orm::Value::from(prefix.clone()),
|
||
],
|
||
))
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(
|
||
plugin = plugin_manifest_id,
|
||
error = %e,
|
||
"分配插件权限给 admin 角色失败"
|
||
);
|
||
PluginError::DatabaseError(format!(
|
||
"分配插件权限给 admin 角色失败: {}",
|
||
e
|
||
))
|
||
})?;
|
||
|
||
let rows = result.rows_affected();
|
||
tracing::info!(
|
||
plugin = plugin_manifest_id,
|
||
rows_affected = rows,
|
||
tenant_id = %tenant_id,
|
||
"插件权限已分配给 admin 角色"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// 清理插件注册的权限(软删除)。
|
||
///
|
||
/// 使用 raw SQL 按前缀匹配清理:`{plugin_manifest_id}.%`。
|
||
/// 同时清理 role_permissions 中对这些权限的关联。
|
||
async fn unregister_plugin_permissions(
|
||
db: &sea_orm::DatabaseConnection,
|
||
tenant_id: Uuid,
|
||
plugin_manifest_id: &str,
|
||
) -> AppResult<()> {
|
||
let prefix = format!("{}.%", plugin_manifest_id);
|
||
let now = chrono::Utc::now();
|
||
|
||
// 先软删除 role_permissions 中的关联
|
||
let rp_sql = r#"
|
||
UPDATE role_permissions
|
||
SET deleted_at = $1, updated_at = $1
|
||
WHERE permission_id IN (
|
||
SELECT id FROM permissions
|
||
WHERE tenant_id = $2
|
||
AND code LIKE $3
|
||
AND deleted_at IS NULL
|
||
)
|
||
AND deleted_at IS NULL
|
||
"#;
|
||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
rp_sql,
|
||
vec![
|
||
sea_orm::Value::from(now.clone()),
|
||
sea_orm::Value::from(tenant_id),
|
||
sea_orm::Value::from(prefix.clone()),
|
||
],
|
||
))
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(
|
||
plugin = plugin_manifest_id,
|
||
error = %e,
|
||
"清理插件权限角色关联失败"
|
||
);
|
||
PluginError::DatabaseError(format!("清理插件权限角色关联失败: {}", e))
|
||
})?;
|
||
|
||
// 再软删除 permissions
|
||
let perm_sql = r#"
|
||
UPDATE permissions
|
||
SET deleted_at = $1, updated_at = $1
|
||
WHERE tenant_id = $2
|
||
AND code LIKE $3
|
||
AND deleted_at IS NULL
|
||
"#;
|
||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
perm_sql,
|
||
vec![
|
||
sea_orm::Value::from(now),
|
||
sea_orm::Value::from(tenant_id),
|
||
sea_orm::Value::from(prefix),
|
||
],
|
||
))
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(
|
||
plugin = plugin_manifest_id,
|
||
error = %e,
|
||
"清理插件权限失败"
|
||
);
|
||
PluginError::DatabaseError(format!("清理插件权限失败: {}", e))
|
||
})?;
|
||
|
||
tracing::info!(
|
||
plugin = plugin_manifest_id,
|
||
tenant_id = %tenant_id,
|
||
"插件权限清理完成"
|
||
);
|
||
Ok(())
|
||
}
|