feat(plugin): P2-P4 插件平台演进 — 通用服务 + 质量保障 + 市场
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

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)
This commit is contained in:
iven
2026-04-19 12:16:24 +08:00
parent c4b1e9e56d
commit e429448c42
20 changed files with 1889 additions and 46 deletions

View File

@@ -14,6 +14,65 @@ use crate::error::PluginError;
use crate::manifest::PluginField;
use crate::state::EntityInfo;
/// 根据 plugin 数据库 ID 查找 manifest 中匹配 entity 的触发事件
async fn find_trigger_events(
plugin_db_id: Uuid,
entity_name: &str,
db: &sea_orm::DatabaseConnection,
) -> AppResult<Vec<crate::manifest::PluginTriggerEvent>> {
let model = plugin::Entity::find_by_id(plugin_db_id)
.one(db)
.await?
.ok_or_else(|| AppError::NotFound(format!("插件 {} 不存在", plugin_db_id)))?;
let manifest: crate::manifest::PluginManifest =
serde_json::from_value(model.manifest_json)
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
let triggers = manifest.trigger_events
.unwrap_or_default()
.into_iter()
.filter(|t| t.entity == entity_name)
.collect();
Ok(triggers)
}
/// 发布触发事件
async fn emit_trigger_events(
triggers: &[crate::manifest::PluginTriggerEvent],
action: &str,
entity_name: &str,
record_id: &str,
tenant_id: Uuid,
data: Option<&serde_json::Value>,
event_bus: &EventBus,
db: &sea_orm::DatabaseConnection,
) {
use crate::manifest::PluginTriggerOn;
for trigger in triggers {
let should_fire = match &trigger.on {
PluginTriggerOn::Create => action == "create",
PluginTriggerOn::Update => action == "update",
PluginTriggerOn::Delete => action == "delete",
PluginTriggerOn::CreateOrUpdate => action == "create" || action == "update",
};
if should_fire {
let payload = serde_json::json!({
"event": trigger.name,
"entity": entity_name,
"record_id": record_id,
"data": data,
});
let event = erp_core::events::DomainEvent::new(
&trigger.name,
tenant_id,
payload,
);
event_bus.publish(event, db).await;
}
}
}
/// 行级数据权限参数 — 传递到 service 层注入 SQL 条件
pub struct DataScopeParams {
pub scope_level: String,
@@ -68,6 +127,11 @@ impl PluginDataService {
)
.await;
// 触发事件发布
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await {
emit_trigger_events(&triggers, "create", entity_name, &result.id.to_string(), tenant_id, Some(&result.data), _event_bus, db).await;
}
Ok(PluginDataResp {
id: result.id.to_string(),
data: result.data,
@@ -279,6 +343,11 @@ impl PluginDataService {
)
.await;
// 触发事件发布
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await {
emit_trigger_events(&triggers, "update", entity_name, &result.id.to_string(), tenant_id, Some(&result.data), _event_bus, db).await;
}
Ok(PluginDataResp {
id: result.id.to_string(),
data: result.data,
@@ -428,6 +497,11 @@ impl PluginDataService {
)
.await;
// 触发事件发布
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await {
emit_trigger_events(&triggers, "delete", entity_name, &id.to_string(), tenant_id, None, _event_bus, db).await;
}
Ok(())
}

View File

@@ -1312,6 +1312,8 @@ mod tests {
relations: vec![],
data_scope: None,
is_public: None,
importable: None,
exportable: None,
};
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
@@ -1355,6 +1357,8 @@ mod tests {
relations: vec![],
data_scope: None,
is_public: None,
importable: None,
exportable: None,
};
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);

View File

@@ -15,9 +15,28 @@ 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::host::{HostState, NumberingRule, PendingOp};
use crate::manifest::PluginManifest;
/// 从 manifest 的 numbering 声明构建 HostState 缓存映射
fn numbering_rules_from_manifest(manifest: &PluginManifest) -> HashMap<String, NumberingRule> {
let mut rules = HashMap::new();
if let Some(numbering) = &manifest.numbering {
for n in numbering {
rules.insert(
n.entity.clone(),
NumberingRule {
prefix: n.prefix.clone(),
format: n.format.clone(),
seq_length: n.seq_length,
reset_rule: format!("{:?}", n.reset_rule).to_lowercase(),
},
);
}
}
rules
}
/// 插件引擎配置
#[derive(Debug, Clone)]
pub struct PluginEngineConfig {
@@ -472,6 +491,9 @@ impl PluginEngine {
// 构建跨插件实体映射(从 manifest 的 ref_plugin 字段提取)
let cross_plugin_entities = Self::build_cross_plugin_map(&loaded.manifest, &self.db, exec_ctx.tenant_id).await;
// 加载插件配置(从数据库)
let plugin_config = Self::load_plugin_config(plugin_id, exec_ctx.tenant_id, &self.db).await;
// 创建新的 Store + HostState使用真实的租户/用户上下文
// 传入 db 和 event_bus 启用混合执行模式(插件可自主查询数据)
let mut state = HostState::new_with_db(
@@ -483,6 +505,9 @@ impl PluginEngine {
self.event_bus.clone(),
);
state.cross_plugin_entities = cross_plugin_entities;
// 注入编号规则和插件配置
state.numbering_rules = numbering_rules_from_manifest(&loaded.manifest);
state.plugin_config = plugin_config;
let mut store = Store::new(&self.engine, state);
store
.set_fuel(self.config.default_fuel)
@@ -541,6 +566,38 @@ impl PluginEngine {
result
}
/// 从数据库加载插件配置(通过 manifest metadata.id 匹配)
fn load_plugin_config(
plugin_id: &str,
tenant_id: Uuid,
db: &DatabaseConnection,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = serde_json::Value> + Send + 'static>> {
let db = db.clone();
let pid = plugin_id.to_string();
Box::pin(async move {
use sea_orm::FromQueryResult;
#[derive(Debug, FromQueryResult)]
struct ConfigRow { config_json: serde_json::Value }
let sql = format!(
"SELECT config_json FROM plugins WHERE tenant_id = '{}'\n\
AND deleted_at IS NULL\n\
AND manifest_json->'metadata'->>'id' = '{}'\n\
LIMIT 1",
tenant_id, pid.replace('\'', "''")
);
ConfigRow::find_by_statement(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
sql,
))
.one(&db)
.await
.ok()
.flatten()
.map(|r| r.config_json)
.unwrap_or_default()
})
}
/// 从 manifest 的 ref_plugin 字段构建跨插件实体映射
/// 返回: { "erp-crm.customer" → "plugin_erp_crm__customer", ... }
async fn build_cross_plugin_map(

View File

@@ -32,6 +32,9 @@ pub enum PluginError {
#[error("权限不足: {0}")]
PermissionDenied(String),
#[error("配置校验失败: {0}")]
ValidationError(String),
}
impl From<PluginError> for AppError {
@@ -41,7 +44,8 @@ impl From<PluginError> for AppError {
PluginError::AlreadyExists(_) => AppError::Conflict(err.to_string()),
PluginError::InvalidManifest(_)
| PluginError::InvalidState { .. }
| PluginError::DependencyNotSatisfied(_) => AppError::Validation(err.to_string()),
| PluginError::DependencyNotSatisfied(_)
| PluginError::ValidationError(_) => AppError::Validation(err.to_string()),
PluginError::PermissionDenied(_) => AppError::Forbidden(err.to_string()),
_ => AppError::Internal(err.to_string()),
}

View File

@@ -456,3 +456,32 @@ where
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
get,
path = "/api/v1/admin/plugins/{id}/validate",
params(("id" = Uuid, Path, description = "插件 ID")),
responses((status = 200, description = "安全验证报告")),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// GET /api/v1/admin/plugins/{id}/validate — 获取插件安全验证报告
pub async fn validate_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<crate::plugin_validator::ValidationReport>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let model = crate::service::find_plugin_model(id, ctx.tenant_id, &state.db).await?;
let manifest: crate::manifest::PluginManifest =
serde_json::from_value(model.manifest_json.clone())
.map_err(|e| AppError::Validation(format!("manifest 解析失败: {}", e)))?;
let report = crate::plugin_validator::validate_plugin_security(&manifest, model.wasm_binary.len())?;
Ok(Json(ApiResponse::ok(report)))
}

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap;
use sea_orm::DatabaseConnection;
use sea_orm::{ConnectionTrait, DatabaseConnection};
use uuid::Uuid;
use wasmtime::StoreLimits;
@@ -58,6 +58,19 @@ pub struct HostState {
pub(crate) event_bus: Option<erp_core::events::EventBus>,
// 跨插件实体映射:"erp-crm.customer" → "plugin_erp_crm__customer"
pub(crate) cross_plugin_entities: HashMap<String, String>,
// 编号规则映射:"invoice" → "INV-{YEAR}-{SEQ:4}"
pub(crate) numbering_rules: HashMap<String, NumberingRule>,
// 插件配置值
pub(crate) plugin_config: serde_json::Value,
}
/// 编号规则缓存
#[derive(Debug, Clone)]
pub struct NumberingRule {
pub prefix: String,
pub format: String,
pub seq_length: u32,
pub reset_rule: String,
}
impl HostState {
@@ -85,6 +98,8 @@ impl HostState {
db: None,
event_bus: None,
cross_plugin_entities: HashMap::new(),
numbering_rules: HashMap::new(),
plugin_config: serde_json::json!({}),
}
}
@@ -289,4 +304,66 @@ impl host_api::Host for HostState {
fn check_permission(&mut self, permission: String) -> Result<bool, String> {
Ok(self.permissions.contains(&permission))
}
fn numbering_generate(&mut self, rule_key: String) -> Result<String, String> {
let rule = self.numbering_rules
.get(&rule_key)
.ok_or_else(|| format!("编号规则 '{}' 未声明", rule_key))?;
let db = self.db.clone()
.ok_or("编号生成需要数据库连接")?;
// 使用 advisory lock 生成编号
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
// 简单实现:基于日期+序列
let now = chrono::Utc::now();
let year = now.format("%Y").to_string();
let month = now.format("%m").to_string();
// 使用 PostgreSQL 序列确保并发安全
use sea_orm::{Statement, FromQueryResult};
#[derive(Debug, FromQueryResult)]
struct SeqVal { nextval: i64 }
let seq_name = format!("plugin_{}_{}_seq", self.plugin_id.replace('-', "_"), rule_key);
let create_sql = format!(
"CREATE SEQUENCE IF NOT EXISTS {} START WITH 1 INCREMENT BY 1",
seq_name
);
let result: Result<sea_orm::ExecResult, sea_orm::DbErr> = db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
create_sql,
)).await;
result.map_err(|e| format!("创建序列失败: {}", e))?;
let seq_sql = format!("SELECT nextval('{}') as nextval", seq_name);
let result: Option<SeqVal> = SeqVal::find_by_statement(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
seq_sql,
)).one(&db).await.map_err(|e| format!("获取序列失败: {}", e))?;
let seq = result.map(|r| r.nextval).unwrap_or(1);
let seq_str = format!("{:0>width$}", seq, width = rule.seq_length as usize);
let number = rule.format
.replace("{PREFIX}", &rule.prefix)
.replace("{YEAR}", &year)
.replace("{MONTH}", &month)
.replace(&format!("{{SEQ:{}}}", rule.seq_length), &seq_str)
.replace("{SEQ}", &seq_str);
Ok(number)
})
}
fn setting_get(&mut self, key: String) -> Result<Vec<u8>, String> {
let config = self.plugin_config.as_object()
.ok_or("插件配置不是有效对象")?;
let value = config.get(&key)
.cloned()
.unwrap_or(serde_json::Value::Null);
serde_json::to_vec(&value).map_err(|e| e.to_string())
}
}

View File

@@ -20,5 +20,6 @@ pub mod handler;
pub mod host;
pub mod manifest;
pub mod module;
pub mod plugin_validator;
pub mod service;
pub mod state;

View File

@@ -10,6 +10,18 @@ pub struct PluginManifest {
pub events: Option<PluginEvents>,
pub ui: Option<PluginUi>,
pub permissions: Option<Vec<PluginPermission>>,
/// 插件配置项声明 — 平台自动生成配置页面
#[serde(default)]
pub settings: Option<PluginSettings>,
/// 编号规则声明 — 绑定实体字段到自动编号
#[serde(default)]
pub numbering: Option<Vec<PluginNumbering>>,
/// 打印模板声明
#[serde(default)]
pub templates: Option<Vec<PluginTemplate>>,
/// 触发事件声明 — 数据 CRUD 时自动发布域事件
#[serde(default)]
pub trigger_events: Option<Vec<PluginTriggerEvent>>,
}
/// 插件元数据
@@ -49,6 +61,10 @@ pub struct PluginEntity {
pub data_scope: Option<bool>, // 是否启用行级数据权限
#[serde(default)]
pub is_public: Option<bool>, // 是否可被其他插件引用
#[serde(default)]
pub importable: Option<bool>, // 是否支持数据导入
#[serde(default)]
pub exportable: Option<bool>, // 是否支持数据导出
}
/// 字段校验规则
@@ -319,6 +335,133 @@ pub struct PluginPermission {
pub data_scope_levels: Option<Vec<String>>, // 支持的数据范围等级
}
// ============================================================
// P2 平台通用服务 — manifest 扩展
// ============================================================
/// 插件配置项声明 — 平台根据此声明自动生成配置页面
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginSettings {
pub fields: Vec<PluginSettingField>,
}
/// 单个配置字段
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginSettingField {
pub name: String,
pub display_name: String,
#[serde(default)]
pub field_type: PluginSettingType,
#[serde(default)]
pub default_value: Option<serde_json::Value>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub description: Option<String>,
/// select/multiselect 类型的选项列表
#[serde(default)]
pub options: Option<Vec<serde_json::Value>>,
/// 数值范围 [min, max]
#[serde(default)]
pub range: Option<(f64, f64)>,
/// 分组名称 — 同组的字段在 UI 上放在一起
#[serde(default)]
pub group: Option<String>,
}
/// 配置字段类型
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PluginSettingType {
#[default]
Text,
Number,
Boolean,
Select,
Multiselect,
Color,
Date,
Datetime,
Json,
}
/// 编号规则声明 — 绑定实体字段到自动编号
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginNumbering {
pub entity: String,
pub field: String,
#[serde(default)]
pub prefix: String,
#[serde(default = "default_numbering_format")]
pub format: String,
#[serde(default)]
pub reset_rule: PluginNumberingReset,
#[serde(default = "default_seq_length")]
pub seq_length: u32,
#[serde(default)]
pub separator: Option<String>,
}
fn default_numbering_format() -> String {
"{PREFIX}-{YEAR}-{SEQ:4}".to_string()
}
fn default_seq_length() -> u32 {
4
}
/// 编号重置周期
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PluginNumberingReset {
#[default]
Never,
Daily,
Monthly,
Yearly,
}
/// 打印模板声明
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginTemplate {
pub name: String,
pub display_name: String,
pub entity: String,
#[serde(default = "default_template_format")]
pub format: String,
/// 模板文件路径(相对于插件根目录)
#[serde(default)]
pub template_file: Option<String>,
/// 内联 HTML 模板(与 template_file 二选一)
#[serde(default)]
pub template_html: Option<String>,
}
fn default_template_format() -> String {
"pdf".to_string()
}
/// 触发事件声明 — 数据 CRUD 操作时自动发布域事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginTriggerEvent {
pub name: String,
pub display_name: String,
#[serde(default)]
pub description: String,
pub entity: String,
pub on: PluginTriggerOn,
}
/// 触发时机
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginTriggerOn {
Create,
Update,
Delete,
CreateOrUpdate,
}
/// 从 TOML 字符串解析插件清单
pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
let manifest: PluginManifest =
@@ -361,6 +504,40 @@ pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
validate_pages(&ui.pages)?;
}
// 验证编号规则引用的实体存在
if let Some(numbering) = &manifest.numbering {
let entity_names: Vec<&str> = manifest
.schema
.as_ref()
.map(|s| s.entities.iter().map(|e| e.name.as_str()).collect())
.unwrap_or_default();
for rule in numbering {
if !entity_names.contains(&rule.entity.as_str()) {
return Err(PluginError::InvalidManifest(format!(
"numbering 引用了不存在的 entity '{}'",
rule.entity
)));
}
}
}
// 验证触发事件引用的实体存在
if let Some(triggers) = &manifest.trigger_events {
let entity_names: Vec<&str> = manifest
.schema
.as_ref()
.map(|s| s.entities.iter().map(|e| e.name.as_str()).collect())
.unwrap_or_default();
for trigger in triggers {
if !entity_names.contains(&trigger.entity.as_str()) {
return Err(PluginError::InvalidManifest(format!(
"trigger_events 引用了不存在的 entity '{}'",
trigger.entity
)));
}
}
}
Ok(manifest)
}
@@ -1110,4 +1287,270 @@ card_title_field = "name"
let result = parse_manifest(toml);
assert!(result.is_err());
}
// ============================================================
// P2 manifest 扩展测试
// ============================================================
#[test]
fn parse_settings_section() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[settings]
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率"
field_type = "number"
default_value = 0.13
range = [0.0, 1.0]
group = "财务"
[[settings.fields]]
name = "invoice_prefix"
display_name = "发票前缀"
field_type = "text"
default_value = "INV"
[[settings.fields]]
name = "auto_notify"
display_name = "自动通知"
field_type = "boolean"
default_value = true
description = "发票创建后是否自动发送通知"
"#;
let manifest = parse_manifest(toml).unwrap();
let settings = manifest.settings.unwrap();
assert_eq!(settings.fields.len(), 3);
assert_eq!(settings.fields[0].name, "default_tax_rate");
assert_eq!(settings.fields[0].range, Some((0.0, 1.0)));
assert_eq!(settings.fields[0].group.as_deref(), Some("财务"));
assert_eq!(settings.fields[1].name, "invoice_prefix");
assert_eq!(settings.fields[2].name, "auto_notify");
assert!(matches!(settings.fields[2].field_type, PluginSettingType::Boolean));
}
#[test]
fn parse_numbering_section() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "invoice"
display_name = "发票"
[[numbering]]
entity = "invoice"
field = "invoice_no"
prefix = "INV"
format = "{PREFIX}-{YEAR}-{SEQ:4}"
reset_rule = "yearly"
seq_length = 4
"#;
let manifest = parse_manifest(toml).unwrap();
let numbering = manifest.numbering.unwrap();
assert_eq!(numbering.len(), 1);
assert_eq!(numbering[0].entity, "invoice");
assert_eq!(numbering[0].field, "invoice_no");
assert_eq!(numbering[0].prefix, "INV");
assert!(matches!(numbering[0].reset_rule, PluginNumberingReset::Yearly));
}
#[test]
fn reject_numbering_with_unknown_entity() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[[numbering]]
entity = "nonexistent"
field = "code"
prefix = "T"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn parse_trigger_events_section() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "invoice"
display_name = "发票"
[[trigger_events]]
name = "invoice.created"
display_name = "发票创建"
description = "新发票创建时触发"
entity = "invoice"
on = "create"
[[trigger_events]]
name = "invoice.overdue"
display_name = "发票逾期"
description = "发票超过付款期限未收款"
entity = "invoice"
on = "update"
"#;
let manifest = parse_manifest(toml).unwrap();
let triggers = manifest.trigger_events.unwrap();
assert_eq!(triggers.len(), 2);
assert_eq!(triggers[0].name, "invoice.created");
assert!(matches!(triggers[0].on, PluginTriggerOn::Create));
assert_eq!(triggers[1].name, "invoice.overdue");
}
#[test]
fn reject_trigger_event_with_unknown_entity() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[[trigger_events]]
name = "test.trigger"
display_name = "测试"
entity = "nonexistent"
on = "create"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn parse_entity_with_import_export() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "product"
display_name = "商品"
importable = true
exportable = true
[[schema.entities]]
name = "internal_log"
display_name = "内部日志"
"#;
let manifest = parse_manifest(toml).unwrap();
let entities = &manifest.schema.unwrap().entities;
assert_eq!(entities[0].importable, Some(true));
assert_eq!(entities[0].exportable, Some(true));
assert_eq!(entities[1].importable, None);
}
#[test]
fn parse_full_p2_manifest() {
let toml = r#"
[metadata]
id = "erp-finance"
name = "财务/应收"
version = "0.1.0"
description = "财务管理与应收账款"
author = "ERP Team"
[schema]
[[schema.entities]]
name = "invoice"
display_name = "发票"
importable = true
exportable = true
[[schema.entities.fields]]
name = "invoice_no"
field_type = "string"
required = true
unique = true
display_name = "发票编号"
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
display_name = "客户"
ref_plugin = "erp-crm"
ref_entity = "customer"
ref_label_field = "name"
ref_search_fields = ["name"]
ref_fallback_label = "外部客户"
[[schema.entities]]
name = "payment"
display_name = "收款"
[settings]
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率"
field_type = "number"
default_value = 0.13
group = "税务"
[[settings.fields]]
name = "invoice_prefix"
display_name = "发票前缀"
field_type = "text"
default_value = "INV"
[[numbering]]
entity = "invoice"
field = "invoice_no"
prefix = "INV"
format = "{PREFIX}-{YEAR}-{SEQ:4}"
reset_rule = "yearly"
[[trigger_events]]
name = "invoice.created"
display_name = "发票创建"
entity = "invoice"
on = "create"
[[permissions]]
code = "invoice.list"
name = "查看发票"
[[permissions]]
code = "invoice.manage"
name = "管理发票"
"#;
let manifest = parse_manifest(toml).unwrap();
assert_eq!(manifest.metadata.id, "erp-finance");
// settings
let settings = manifest.settings.unwrap();
assert_eq!(settings.fields.len(), 2);
// numbering
let numbering = manifest.numbering.unwrap();
assert_eq!(numbering.len(), 1);
assert_eq!(numbering[0].entity, "invoice");
// trigger_events
let triggers = manifest.trigger_events.unwrap();
assert_eq!(triggers.len(), 1);
// import/export on entity
let entities = &manifest.schema.unwrap().entities;
assert_eq!(entities[0].importable, Some(true));
assert_eq!(entities[0].exportable, Some(true));
}
}

View File

@@ -66,6 +66,10 @@ impl PluginModule {
.route(
"/admin/plugins/{id}/upgrade",
post(crate::handler::plugin_handler::upgrade_plugin::<S>),
)
.route(
"/admin/plugins/{id}/validate",
get(crate::handler::plugin_handler::validate_plugin::<S>),
);
// 插件数据 CRUD 路由

View File

@@ -0,0 +1,304 @@
use crate::error::{PluginError, PluginResult};
use crate::manifest::{parse_manifest, PluginManifest};
/// 插件上传时校验报告
#[derive(Debug, Clone, serde::Serialize)]
pub struct ValidationReport {
pub valid: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
pub metrics: PluginMetrics,
}
/// 插件质量指标
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct PluginMetrics {
pub entity_count: usize,
pub field_count: usize,
pub page_count: usize,
pub permission_count: usize,
pub relation_count: usize,
pub has_import_export: bool,
pub has_settings: bool,
pub has_numbering: bool,
pub has_trigger_events: bool,
pub wasm_size_bytes: usize,
pub complexity_score: f64,
}
/// 运行时监控指标
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct RuntimeMetrics {
pub error_count: u64,
pub total_invocations: u64,
pub avg_response_ms: f64,
pub fuel_consumption_avg: f64,
pub memory_peak_bytes: u64,
pub last_error: Option<String>,
pub last_error_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl RuntimeMetrics {
pub fn error_rate(&self) -> f64 {
if self.total_invocations == 0 {
return 0.0;
}
self.error_count as f64 / self.total_invocations as f64
}
}
/// 上传时安全扫描
pub fn validate_plugin_security(manifest: &PluginManifest, wasm_size: usize) -> PluginResult<ValidationReport> {
let mut errors = Vec::new();
let mut warnings = Vec::new();
// 1. WASM 大小检查(上限 10MB
if wasm_size > 10 * 1024 * 1024 {
errors.push(format!("WASM 文件过大: {} bytes (上限 10MB)", wasm_size));
} else if wasm_size > 5 * 1024 * 1024 {
warnings.push(format!("WASM 文件较大: {} bytes (>5MB)", wasm_size));
}
// 2. 实体数量检查(上限 20
if let Some(schema) = &manifest.schema {
if schema.entities.len() > 20 {
errors.push(format!("实体数量过多: {} (上限 20)", schema.entities.len()));
}
for entity in &schema.entities {
// 字段数量检查
if entity.fields.len() > 50 {
errors.push(format!(
"实体 '{}' 字段数量过多: {} (上限 50)",
entity.name, entity.fields.len()
));
}
// 索引数量检查
if entity.indexes.len() > 10 {
warnings.push(format!(
"实体 '{}' 索引数量较多: {} (>10 可能影响写入性能)",
entity.name, entity.indexes.len()
));
}
// 检查字段中有无潜在 SQL 注入风险的字段名
for field in &entity.fields {
if field.name.len() > 64 {
errors.push(format!(
"字段名过长: '{}.{}' (上限 64 字符)",
entity.name, field.name
));
}
if !field.name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
errors.push(format!(
"字段名包含非法字符: '{}.{}' (只允许字母、数字、下划线)",
entity.name, field.name
));
}
}
}
}
// 3. 权限码命名规范检查
if let Some(permissions) = &manifest.permissions {
for perm in permissions {
if !perm.code.contains('.') {
warnings.push(format!(
"权限码 '{}' 建议使用 'entity.action' 格式",
perm.code
));
}
}
}
// 4. 依赖检查
if manifest.metadata.dependencies.len() > 5 {
warnings.push(format!(
"依赖数量较多: {} (>5 可能增加安装复杂度)",
manifest.metadata.dependencies.len()
));
}
// 5. 计算复杂度分数
let mut metrics = collect_metrics(manifest, wasm_size);
metrics.complexity_score = calculate_complexity_score(&metrics);
if metrics.complexity_score > 80.0 {
warnings.push(format!(
"插件复杂度较高: {:.1} (>80 建议拆分)",
metrics.complexity_score
));
}
let valid = errors.is_empty();
Ok(ValidationReport {
valid,
errors,
warnings,
metrics,
})
}
/// 收集插件指标
fn collect_metrics(manifest: &PluginManifest, wasm_size: usize) -> PluginMetrics {
let mut metrics = PluginMetrics {
wasm_size_bytes: wasm_size,
..Default::default()
};
if let Some(schema) = &manifest.schema {
metrics.entity_count = schema.entities.len();
for entity in &schema.entities {
metrics.field_count += entity.fields.len();
metrics.relation_count += entity.relations.len();
if entity.importable == Some(true) || entity.exportable == Some(true) {
metrics.has_import_export = true;
}
}
}
if let Some(ui) = &manifest.ui {
metrics.page_count = count_pages(&ui.pages);
}
if let Some(permissions) = &manifest.permissions {
metrics.permission_count = permissions.len();
}
metrics.has_settings = manifest.settings.is_some();
metrics.has_numbering = manifest.numbering.as_ref().map_or(false, |n| !n.is_empty());
metrics.has_trigger_events = manifest.trigger_events.as_ref().map_or(false, |t| !t.is_empty());
metrics
}
fn count_pages(pages: &[crate::manifest::PluginPageType]) -> usize {
let mut count = 0;
for page in pages {
count += 1;
if let crate::manifest::PluginPageType::Tabs { tabs, .. } = page {
count += count_pages(tabs);
}
}
count
}
/// 计算复杂度分数0-100
fn calculate_complexity_score(metrics: &PluginMetrics) -> f64 {
let entity_score = (metrics.entity_count as f64 / 20.0) * 30.0;
let field_score = (metrics.field_count as f64 / 100.0) * 20.0;
let page_score = (metrics.page_count as f64 / 20.0) * 15.0;
let relation_score = (metrics.relation_count as f64 / 30.0) * 15.0;
let size_score = (metrics.wasm_size_bytes as f64 / (10.0 * 1024.0 * 1024.0)) * 20.0;
(entity_score + field_score + page_score + relation_score + size_score).min(100.0)
}
/// 性能基准测试结果
#[derive(Debug, Clone, serde::Serialize)]
pub struct BenchmarkResult {
pub create_avg_ms: f64,
pub read_avg_ms: f64,
pub update_avg_ms: f64,
pub delete_avg_ms: f64,
pub list_avg_ms: f64,
pub passed: bool,
pub details: String,
}
impl BenchmarkResult {
/// 创建操作的阈值: 500ms
pub const CREATE_THRESHOLD_MS: f64 = 500.0;
/// 读取操作的阈值: 200ms
pub const READ_THRESHOLD_MS: f64 = 200.0;
/// 列表查询的阈值: 1000ms
pub const LIST_THRESHOLD_MS: f64 = 1000.0;
pub fn check(&self) -> bool {
self.create_avg_ms <= Self::CREATE_THRESHOLD_MS
&& self.read_avg_ms <= Self::READ_THRESHOLD_MS
&& self.list_avg_ms <= Self::LIST_THRESHOLD_MS
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_security_basic() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "product"
display_name = "商品"
[[schema.entities.fields]]
name = "sku"
field_type = "string"
required = true
"#;
let manifest = parse_manifest(toml).unwrap();
let report = validate_plugin_security(&manifest, 1024).unwrap();
assert!(report.valid);
assert!(report.errors.is_empty());
}
#[test]
fn reject_oversized_wasm() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
"#;
let manifest = parse_manifest(toml).unwrap();
let report = validate_plugin_security(&manifest, 15 * 1024 * 1024).unwrap();
assert!(!report.valid);
assert!(report.errors.iter().any(|e| e.contains("WASM 文件过大")));
}
#[test]
fn complexity_score_calculation() {
let metrics = PluginMetrics {
entity_count: 5,
field_count: 30,
page_count: 5,
relation_count: 3,
wasm_size_bytes: 500_000,
..Default::default()
};
let score = calculate_complexity_score(&metrics);
assert!(score > 0.0 && score < 50.0, "score = {}", score);
}
#[test]
fn runtime_metrics_error_rate() {
let metrics = RuntimeMetrics {
error_count: 5,
total_invocations: 100,
..Default::default()
};
assert!((metrics.error_rate() - 0.05).abs() < 0.001);
}
#[test]
fn benchmark_threshold_check() {
let result = BenchmarkResult {
create_avg_ms: 300.0,
read_avg_ms: 100.0,
update_avg_ms: 200.0,
delete_avg_ms: 150.0,
list_avg_ms: 800.0,
passed: true,
details: String::new(),
};
assert!(result.check());
}
}

View File

@@ -29,6 +29,14 @@ impl PluginService {
// 解析 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);
@@ -403,6 +411,10 @@ impl PluginService {
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();
@@ -439,6 +451,16 @@ impl PluginService {
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);
@@ -446,9 +468,6 @@ impl PluginService {
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))
}
@@ -489,7 +508,7 @@ impl PluginService {
serde_json::from_value(model.manifest_json.clone())
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
// 构建 schema 响应entities + ui 页面配置
// 构建 schema 响应entities + ui 页面配置 + settings + numbering + trigger_events
let mut result = serde_json::Map::new();
if let Some(schema) = &manifest.schema {
result.insert(
@@ -503,6 +522,24 @@ impl PluginService {
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))
}
@@ -681,6 +718,15 @@ fn find_plugin(
}
}
/// 公开的插件查询 — 供 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],
@@ -754,6 +800,77 @@ fn validate_status_any(actual: &str, expected: &[&str]) -> AppResult<()> {
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,

View File

@@ -28,6 +28,12 @@ interface host-api {
/// 检查当前用户权限
check-permission: func(permission: string) -> result<bool, string>;
/// 根据编号规则生成下一个编号(如 INV-2026-0001
numbering-generate: func(rule-key: string) -> result<string, string>;
/// 读取插件配置项
setting-get: func(key: string) -> result<list<u8>, string>;
}
/// 插件导出的 API宿主调用这些函数