Files
erp/crates/erp-plugin/src/service.rs
iven e429448c42
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
feat(plugin): P2-P4 插件平台演进 — 通用服务 + 质量保障 + 市场
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)
2026-04-19 12:16:24 +08:00

1117 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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());
// 批量查询所有插件的 entitiesN+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(())
}