feat: initialize ERP base platform (extracted from HMS)

- Stripped 11 business crates (health, ai, dialysis, plugins)
- Cleaned AppState, AppConfig, main.rs from business coupling
- Reduced migrations from 169 to 53 (base-only)
- Removed health_provider trait from erp-core
- Removed business integration tests
- Removed gateway rate limiting middleware
- Base capabilities: auth, RBAC, JWT, config, workflow, message, plugin, audit, crypto, RLS, multi-tenant

Cargo check: OK
Cargo test: OK
This commit is contained in:
iven
2026-05-31 20:35:57 +08:00
commit 59856ac2fc
639 changed files with 124710 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
[package]
name = "erp-plugin"
version = "0.1.0"
edition = "2024"
description = "ERP WASM 插件运行时 — 生产级 Host API"
[dependencies]
wasmtime = "43"
wasmtime-wasi = "43"
erp-core = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sea-orm = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
dashmap = "6"
toml = "0.8"
axum = { workspace = true }
utoipa = { workspace = true }
async-trait = { workspace = true }
sha2 = { workspace = true }
base64 = "0.22"
moka = { version = "0.12", features = ["sync"] }
regex = "1"
csv = { workspace = true }
rust_xlsxwriter = { workspace = true }
validator = { workspace = true }

View File

@@ -0,0 +1,331 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
/// 插件数据记录响应
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct PluginDataResp {
pub id: String,
pub data: serde_json::Value,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub version: Option<i32>,
}
/// 创建插件数据请求
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreatePluginDataReq {
pub data: serde_json::Value,
}
/// 更新插件数据请求(全量替换)
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdatePluginDataReq {
pub data: serde_json::Value,
pub version: i32,
}
/// 部分更新请求PATCH — 只合并提供的字段)
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct PatchPluginDataReq {
pub data: serde_json::Value,
pub version: i32,
}
/// 插件数据列表查询参数
#[derive(Debug, Serialize, Deserialize, IntoParams)]
pub struct PluginDataListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
/// Base64 编码的游标(用于 Keyset 分页)
pub cursor: Option<String>,
pub search: Option<String>,
/// JSON 格式过滤: {"field":"value"}
pub filter: Option<String>,
pub sort_by: Option<String>,
/// "asc" or "desc"
pub sort_order: Option<String>,
}
/// 聚合查询响应项
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AggregateItem {
/// 分组键(字段值)
pub key: String,
/// 计数
pub count: i64,
}
/// 多聚合查询响应项
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AggregateMultiRow {
/// 分组键
pub key: String,
/// 计数
pub count: i64,
/// 聚合指标: {"sum_amount": 5000.0, "avg_price": 25.5}
#[serde(default)]
pub metrics: std::collections::HashMap<String, f64>,
}
/// 聚合查询参数
#[derive(Debug, Serialize, Deserialize, IntoParams)]
pub struct AggregateQueryParams {
/// 分组字段名
pub group_by: String,
/// JSON 格式过滤: {"field":"value"}
pub filter: Option<String>,
}
/// 多聚合查询请求体
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AggregateMultiReq {
/// 分组字段名
pub group_by: String,
/// 聚合定义列表: [{"func": "sum", "field": "amount"}]
pub aggregations: Vec<AggregateDefDto>,
/// JSON 格式过滤
pub filter: Option<serde_json::Value>,
}
/// 单个聚合定义
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AggregateDefDto {
/// 聚合函数: count, sum, avg, min, max
pub func: String,
/// 字段名
pub field: String,
}
/// 统计查询参数
#[derive(Debug, Serialize, Deserialize, IntoParams)]
pub struct CountQueryParams {
/// 搜索关键词
pub search: Option<String>,
/// JSON 格式过滤: {"field":"value"}
pub filter: Option<String>,
}
/// 批量操作请求
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct BatchActionReq {
/// 操作类型: "batch_delete" 或 "batch_update"
pub action: String,
/// 记录 ID 列表(上限 100
pub ids: Vec<String>,
/// batch_update 时的更新数据
pub data: Option<serde_json::Value>,
}
/// 时间序列查询参数
#[derive(Debug, Serialize, Deserialize, IntoParams)]
pub struct TimeseriesParams {
/// 时间字段名
pub time_field: String,
/// 时间粒度: "day" / "week" / "month"
pub time_grain: String,
/// 开始日期 (ISO)
pub start: Option<String>,
/// 结束日期 (ISO)
pub end: Option<String>,
}
/// 时间序列数据项
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct TimeseriesItem {
/// 时间周期
pub period: String,
/// 计数
pub count: i64,
}
// ─── 跨插件引用 DTO ──────────────────────────────────────────────────
/// 批量标签解析请求
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ResolveLabelsReq {
/// 字段名 → UUID 列表
pub fields: std::collections::HashMap<String, Vec<String>>,
}
/// 批量标签解析响应
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ResolveLabelsResp {
/// 字段名 → { uuid: label } 映射
pub labels: serde_json::Value,
/// 字段名 → 目标插件元信息
pub meta: serde_json::Value,
}
/// 公开实体信息(实体注册表查询响应)
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct PublicEntityResp {
pub manifest_id: String,
pub plugin_id: String,
pub entity_name: String,
pub display_name: String,
}
// ─── 导入导出 DTO ──────────────────────────────────────────────────
/// 数据导出参数
#[derive(Debug, Serialize, Deserialize, IntoParams)]
pub struct ExportParams {
/// JSON 格式过滤: {"field":"value"}
pub filter: Option<String>,
/// 搜索关键词
pub search: Option<String>,
/// 排序字段
pub sort_by: Option<String>,
/// "asc" or "desc"
pub sort_order: Option<String>,
/// 导出格式: "json" (默认) | "csv" | "xlsx"
pub format: Option<String>,
}
/// 导出结果 — 根据格式返回不同内容
pub enum ExportPayload {
Json(Vec<serde_json::Value>),
Csv(Vec<u8>),
Xlsx(Vec<u8>),
}
/// 数据导入请求
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ImportReq {
/// 导入数据行列表,每行是一个 JSON 对象
pub rows: Vec<serde_json::Value>,
}
/// 数据导入结果
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ImportResult {
/// 成功导入行数
pub success_count: usize,
/// 失败行数
pub error_count: usize,
/// 每行错误详情: [{ row: 0, errors: ["字段 xxx 必填"] }]
#[serde(default)]
pub errors: Vec<ImportRowError>,
}
/// 单行导入错误
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ImportRowError {
/// 行号0-based
pub row: usize,
/// 错误消息列表
pub errors: Vec<String>,
}
// ─── 市场目录 DTO ──────────────────────────────────────────────────
/// 市场条目列表查询参数
#[derive(Debug, Serialize, Deserialize, IntoParams)]
pub struct MarketListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub category: Option<String>,
pub search: Option<String>,
}
/// 市场条目响应(不含二进制数据)
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct MarketEntryResp {
pub id: String,
pub plugin_id: String,
pub name: String,
pub version: String,
pub description: Option<String>,
pub author: Option<String>,
pub category: Option<String>,
pub tags: Option<serde_json::Value>,
pub icon_url: Option<String>,
pub screenshots: Option<serde_json::Value>,
pub min_platform_version: Option<String>,
pub status: String,
pub download_count: i32,
pub rating_avg: f64,
pub rating_count: i32,
pub changelog: Option<String>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// 市场条目详情响应(含完整信息)
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct MarketEntryDetailResp {
#[serde(flatten)]
pub entry: MarketEntryResp,
/// 依赖提示(安装时检查 manifest.dependencies
pub dependency_warnings: Vec<String>,
}
/// 提交评分/评论请求
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SubmitReviewReq {
/// 评分 1-5
pub rating: i32,
/// 评论内容
pub review_text: Option<String>,
}
/// 评论响应
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct MarketReviewResp {
pub id: String,
pub user_id: String,
pub market_entry_id: String,
pub rating: i32,
pub review_text: Option<String>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
// ─── 对账扫描 DTO ──────────────────────────────────────────────────
/// 对账报告
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ReconciliationReport {
/// 有效引用数
pub valid_count: i64,
/// 悬空引用数
pub dangling_count: i64,
/// 悬空引用详情
pub details: Vec<DanglingRef>,
}
/// 悬空引用详情
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct DanglingRef {
/// 实体名
pub entity: String,
/// 字段名
pub field: String,
/// 记录 ID
pub record_id: String,
/// 悬空的 UUID 值
pub dangling_value: String,
}
// ─── 自定义视图 DTO ──────────────────────────────────────────────────
/// 用户视图配置请求
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UserViewReq {
pub view_name: String,
pub view_config: serde_json::Value,
pub is_default: Option<bool>,
}
/// 用户视图响应
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UserViewResp {
pub id: String,
pub plugin_id: String,
pub entity_name: String,
pub view_name: String,
pub view_config: serde_json::Value,
pub is_default: bool,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::Validate;
/// 插件信息响应
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PluginResp {
pub id: Uuid,
pub name: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
pub status: String,
pub config: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub installed_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_at: Option<DateTime<Utc>>,
pub entities: Vec<PluginEntityResp>,
#[serde(skip_serializing_if = "Option::is_none")]
pub permissions: Option<Vec<PluginPermissionResp>>,
pub record_version: i32,
}
/// 插件实体信息
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PluginEntityResp {
pub name: String,
pub display_name: String,
pub table_name: String,
}
/// 插件权限信息
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PluginPermissionResp {
pub code: String,
pub name: String,
pub description: String,
}
/// 插件健康检查响应
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PluginHealthResp {
pub plugin_id: Uuid,
pub status: String,
pub details: serde_json::Value,
}
/// 更新插件配置请求
#[derive(Debug, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct UpdatePluginConfigReq {
pub config: serde_json::Value,
pub version: i32,
}
/// 插件列表查询参数
#[derive(Debug, Serialize, Deserialize, Validate, utoipa::IntoParams)]
pub struct PluginListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
#[validate(length(max = 20, message = "状态值无效"))]
pub status: Option<String>,
#[validate(length(max = 100, message = "搜索关键词过长"))]
pub search: Option<String>,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,875 @@
use std::collections::HashMap;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use dashmap::DashMap;
use sea_orm::{
ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, Statement,
TransactionTrait,
};
use serde_json::json;
use tokio::sync::RwLock;
use uuid::Uuid;
use wasmtime::component::{Component, HasSelf, Linker};
use wasmtime::{Config, Engine, Store};
use erp_core::events::EventBus;
use crate::PluginWorld;
use crate::dynamic_table::DynamicTableManager;
use crate::error::{PluginError, PluginResult};
use crate::host::{HostState, 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 {
/// 默认 Fuel 限制
pub default_fuel: u64,
/// 执行超时(秒)
pub execution_timeout_secs: u64,
}
impl Default for PluginEngineConfig {
fn default() -> Self {
Self {
default_fuel: 10_000_000,
execution_timeout_secs: 30,
}
}
}
/// 插件运行状态
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginStatus {
/// 已加载到内存
Loaded,
/// 已初始化init() 已调用)
Initialized,
/// 运行中(事件监听已启动)
Running,
/// 错误状态
Error(String),
/// 已禁用
Disabled,
}
/// 已加载的插件实例
pub struct LoadedPlugin {
pub id: String,
pub manifest: PluginManifest,
pub component: Component,
pub linker: Linker<HostState>,
pub status: RwLock<PluginStatus>,
pub event_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
pub metrics: Arc<RwLock<RuntimeMetrics>>,
}
/// 插件运行时指标
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct RuntimeMetrics {
pub total_invocations: u64,
pub error_count: u64,
pub total_response_ms: f64,
pub fuel_consumed_total: u64,
pub memory_peak_bytes: u64,
pub last_error: Option<String>,
pub last_invocation_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// WASM 执行上下文 — 传递真实的租户和用户信息
#[derive(Debug, Clone)]
pub struct ExecutionContext {
pub tenant_id: Uuid,
pub user_id: Uuid,
pub permissions: Vec<String>,
}
/// 插件引擎 — 管理所有已加载插件的 WASM 运行时
#[derive(Clone)]
pub struct PluginEngine {
engine: Arc<Engine>,
db: DatabaseConnection,
event_bus: EventBus,
plugins: Arc<DashMap<String, Arc<LoadedPlugin>>>,
config: PluginEngineConfig,
}
impl PluginEngine {
/// 创建新的插件引擎
pub fn new(
db: DatabaseConnection,
event_bus: EventBus,
config: PluginEngineConfig,
) -> PluginResult<Self> {
let mut wasm_config = Config::new();
wasm_config.wasm_component_model(true);
wasm_config.consume_fuel(true);
let engine = Engine::new(&wasm_config)
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
Ok(Self {
engine: Arc::new(engine),
db,
event_bus,
plugins: Arc::new(DashMap::new()),
config,
})
}
/// 加载插件到内存(不初始化)
pub async fn load(
&self,
plugin_id: &str,
wasm_bytes: &[u8],
manifest: PluginManifest,
) -> PluginResult<()> {
if self.plugins.contains_key(plugin_id) {
return Err(PluginError::AlreadyExists(plugin_id.to_string()));
}
let component = Component::from_binary(&self.engine, wasm_bytes)
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
let mut linker = Linker::new(&self.engine);
// 注册 Host API 到 Linker
PluginWorld::add_to_linker::<_, HasSelf<HostState>>(&mut linker, |state| state)
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
let loaded = Arc::new(LoadedPlugin {
id: plugin_id.to_string(),
manifest,
component,
linker,
status: RwLock::new(PluginStatus::Loaded),
event_handles: RwLock::new(vec![]),
metrics: Arc::new(RwLock::new(RuntimeMetrics::default())),
});
self.plugins.insert(plugin_id.to_string(), loaded);
tracing::info!(plugin_id, "Plugin loaded into memory");
Ok(())
}
/// 初始化插件(调用 init()
pub async fn initialize(&self, plugin_id: &str) -> PluginResult<()> {
let loaded = self.get_loaded(plugin_id)?;
// 检查状态
{
let status = loaded.status.read().await;
if *status != PluginStatus::Loaded {
return Err(PluginError::InvalidState {
expected: "Loaded".to_string(),
actual: format!("{:?}", *status),
});
}
}
let ctx = ExecutionContext {
tenant_id: Uuid::nil(),
user_id: Uuid::nil(),
permissions: vec![],
};
let result = self
.execute_wasm(plugin_id, &ctx, |store, instance| {
instance
.erp_plugin_plugin_api()
.call_init(store)
.map_err(|e| PluginError::ExecutionError(e.to_string()))?
.map_err(PluginError::ExecutionError)?;
Ok(())
})
.await;
match result {
Ok(()) => {
*loaded.status.write().await = PluginStatus::Initialized;
tracing::info!(plugin_id, "Plugin initialized");
Ok(())
}
Err(e) => {
*loaded.status.write().await = PluginStatus::Error(e.to_string());
Err(e)
}
}
}
/// 启动事件监听
pub async fn start_event_listener(&self, plugin_id: &str) -> PluginResult<()> {
let loaded = self.get_loaded(plugin_id)?;
// 检查状态
{
let status = loaded.status.read().await;
if *status != PluginStatus::Initialized {
return Err(PluginError::InvalidState {
expected: "Initialized".to_string(),
actual: format!("{:?}", *status),
});
}
}
let events_config = &loaded.manifest.events;
if let Some(events) = events_config {
for pattern in &events.subscribe {
let (mut rx, sub_handle) = self.event_bus.subscribe_filtered(pattern.clone());
let pid = plugin_id.to_string();
let engine = self.clone();
let join_handle = tokio::spawn(async move {
// sub_handle 保存在此 task 中task 结束时自动 drop 触发优雅取消
let _sub_guard = sub_handle;
while let Some(event) = rx.recv().await {
if let Err(e) = engine
.handle_event_inner(
&pid,
&event.event_type,
&event.payload,
event.tenant_id,
)
.await
{
tracing::error!(
plugin_id = %pid,
error = %e,
"Plugin event handler failed"
);
}
}
});
loaded.event_handles.write().await.push(join_handle);
}
}
*loaded.status.write().await = PluginStatus::Running;
tracing::info!(plugin_id, "Plugin event listener started");
Ok(())
}
/// 处理单个事件
pub async fn handle_event(
&self,
plugin_id: &str,
event_type: &str,
payload: &serde_json::Value,
tenant_id: Uuid,
) -> PluginResult<()> {
self.handle_event_inner(plugin_id, event_type, payload, tenant_id)
.await
}
async fn handle_event_inner(
&self,
plugin_id: &str,
event_type: &str,
payload: &serde_json::Value,
tenant_id: Uuid,
) -> PluginResult<()> {
let payload_bytes = serde_json::to_vec(payload).unwrap_or_default();
let event_type = event_type.to_owned();
let ctx = ExecutionContext {
tenant_id,
user_id: Uuid::nil(),
permissions: vec![],
};
self.execute_wasm(plugin_id, &ctx, move |store, instance| {
instance
.erp_plugin_plugin_api()
.call_handle_event(store, &event_type, &payload_bytes)
.map_err(|e| PluginError::ExecutionError(e.to_string()))?
.map_err(PluginError::ExecutionError)?;
Ok(())
})
.await
}
/// 租户创建时调用插件的 on_tenant_created
pub async fn on_tenant_created(&self, plugin_id: &str, tenant_id: Uuid) -> PluginResult<()> {
let tenant_id_str = tenant_id.to_string();
let ctx = ExecutionContext {
tenant_id,
user_id: Uuid::nil(),
permissions: vec![],
};
self.execute_wasm(plugin_id, &ctx, move |store, instance| {
instance
.erp_plugin_plugin_api()
.call_on_tenant_created(store, &tenant_id_str)
.map_err(|e| PluginError::ExecutionError(e.to_string()))?
.map_err(PluginError::ExecutionError)?;
Ok(())
})
.await
}
/// 禁用插件(停止事件监听 + 更新状态)
pub async fn disable(&self, plugin_id: &str) -> PluginResult<()> {
let loaded = self.get_loaded(plugin_id)?;
// 取消所有事件监听
let mut handles = loaded.event_handles.write().await;
for handle in handles.drain(..) {
handle.abort();
}
drop(handles);
*loaded.status.write().await = PluginStatus::Disabled;
tracing::info!(plugin_id, "Plugin disabled");
Ok(())
}
/// 从内存卸载插件
pub async fn unload(&self, plugin_id: &str) -> PluginResult<()> {
if self.plugins.contains_key(plugin_id) {
self.disable(plugin_id).await.ok();
}
self.plugins.remove(plugin_id);
tracing::info!(plugin_id, "Plugin unloaded");
Ok(())
}
/// 将插件从一个 key 重命名为另一个 key用于热更新的原子替换
pub async fn rename_plugin(&self, old_id: &str, new_id: &str) -> PluginResult<()> {
let (_, loaded) = self
.plugins
.remove(old_id)
.ok_or_else(|| PluginError::NotFound(old_id.to_string()))?;
let mut loaded = Arc::try_unwrap(loaded)
.map_err(|_| PluginError::ExecutionError("插件仍被引用,无法重命名".to_string()))?;
loaded.id = new_id.to_string();
self.plugins.insert(new_id.to_string(), Arc::new(loaded));
tracing::info!(old_id, new_id, "Plugin renamed");
Ok(())
}
/// 健康检查
pub async fn health_check(&self, plugin_id: &str) -> PluginResult<serde_json::Value> {
let loaded = self.get_loaded(plugin_id)?;
let status = loaded.status.read().await;
match &*status {
PluginStatus::Running => Ok(json!({
"status": "healthy",
"plugin_id": plugin_id,
})),
PluginStatus::Error(e) => Ok(json!({
"status": "error",
"plugin_id": plugin_id,
"error": e,
})),
other => Ok(json!({
"status": "unhealthy",
"plugin_id": plugin_id,
"state": format!("{:?}", other),
})),
}
}
/// 列出所有已加载插件的信息
pub fn list_plugins(&self) -> Vec<PluginInfo> {
self.plugins
.iter()
.map(|entry| {
let loaded = entry.value();
PluginInfo {
id: loaded.id.clone(),
name: loaded.manifest.metadata.name.clone(),
version: loaded.manifest.metadata.version.clone(),
}
})
.collect()
}
/// 获取插件清单
pub fn get_manifest(&self, plugin_id: &str) -> Option<PluginManifest> {
self.plugins
.get(plugin_id)
.map(|entry| entry.manifest.clone())
}
/// 获取插件运行时指标
pub async fn get_metrics(&self, plugin_id: &str) -> PluginResult<RuntimeMetrics> {
let loaded = self.get_loaded(plugin_id)?;
let metrics = loaded.metrics.read().await;
Ok(metrics.clone())
}
/// 刷新插件内存配置(配置变更后调用)
pub async fn refresh_config(&self, plugin_id: &str) -> PluginResult<()> {
// 扫描所有已加载插件,找到匹配 manifest_id 的插件
for entry in self.plugins.iter() {
if entry.value().id == plugin_id {
// 配置会在下次 execute_wasm 时从数据库自动重新加载
// 这里只清理可能缓存的旧配置
tracing::info!(
plugin_id,
"Plugin config refresh scheduled (loaded on next invocation)"
);
return Ok(());
}
}
Ok(())
}
/// 检查插件是否正在运行
pub async fn is_running(&self, plugin_id: &str) -> bool {
if let Some(loaded) = self.plugins.get(plugin_id) {
matches!(*loaded.status.read().await, PluginStatus::Running)
} else {
false
}
}
/// 恢复数据库中状态为 running/enabled 的插件。
///
/// 服务器重启后调用此方法,重新加载 WASM 到内存并启动事件监听。
pub async fn recover_plugins(&self, db: &DatabaseConnection) -> PluginResult<Vec<String>> {
use crate::entity::plugin;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
// 查询所有运行中的插件
let running_plugins = plugin::Entity::find()
.filter(plugin::Column::Status.eq("running"))
.filter(plugin::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
let mut recovered = Vec::new();
for model in running_plugins {
let tenant_id = model.tenant_id;
let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone())
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
let plugin_id_str = &manifest.metadata.id;
// 跳过已被其他租户加载的同 ID 插件WASM 二进制相同,数据隔离在 DB 层)
if self.plugins.contains_key(plugin_id_str) {
tracing::info!(
plugin_id = %plugin_id_str,
tenant_id = %tenant_id,
"Plugin already loaded by another tenant, skipping duplicate load"
);
recovered.push(plugin_id_str.clone());
continue;
}
// 加载 WASM 到内存
if let Err(e) = self
.load(plugin_id_str, &model.wasm_binary, manifest.clone())
.await
{
tracing::error!(
plugin_id = %plugin_id_str,
tenant_id = %tenant_id,
error = %e,
"Failed to recover plugin (load)"
);
continue;
}
// 初始化
if let Err(e) = self.initialize(plugin_id_str).await {
tracing::error!(
plugin_id = %plugin_id_str,
tenant_id = %tenant_id,
error = %e,
"Failed to recover plugin (initialize)"
);
continue;
}
// 启动事件监听
if let Err(e) = self.start_event_listener(plugin_id_str).await {
tracing::error!(
plugin_id = %plugin_id_str,
tenant_id = %tenant_id,
error = %e,
"Failed to recover plugin (start_event_listener)"
);
continue;
}
tracing::info!(
plugin_id = %plugin_id_str,
tenant_id = %tenant_id,
"Plugin recovered"
);
recovered.push(plugin_id_str.clone());
}
tracing::info!(count = recovered.len(), "Plugins recovered");
Ok(recovered)
}
// ---- 内部方法 ----
fn get_loaded(&self, plugin_id: &str) -> PluginResult<Arc<LoadedPlugin>> {
self.plugins
.get(plugin_id)
.map(|e| e.value().clone())
.ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))
}
/// 在 spawn_blocking + catch_unwind + fuel + timeout 中执行 WASM 操作,
/// 执行完成后自动刷新 pending_ops 到数据库。
async fn execute_wasm<F, R>(
&self,
plugin_id: &str,
exec_ctx: &ExecutionContext,
operation: F,
) -> PluginResult<R>
where
F: FnOnce(&mut Store<HostState>, &PluginWorld) -> PluginResult<R>
+ Send
+ std::panic::UnwindSafe
+ 'static,
R: Send + 'static,
{
let loaded = self.get_loaded(plugin_id)?;
// 构建跨插件实体映射(从 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(
plugin_id.to_string(),
exec_ctx.tenant_id,
exec_ctx.user_id,
exec_ctx.permissions.clone(),
self.db.clone(),
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)
.map_err(|e| PluginError::ExecutionError(e.to_string()))?;
store.limiter(|state| &mut state.limits);
// 实例化
let instance =
PluginWorld::instantiate_async(&mut store, &loaded.component, &loaded.linker)
.await
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
let timeout_secs = self.config.execution_timeout_secs;
let pid_owned = plugin_id.to_owned();
let start = std::time::Instant::now();
// spawn_blocking 闭包执行 WASM正常完成时收集 pending_ops
let (result, pending_ops): (PluginResult<R>, Vec<PendingOp>) = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
tokio::task::spawn_blocking(move || {
match std::panic::catch_unwind(AssertUnwindSafe(|| {
let r = operation(&mut store, &instance);
// catch_unwind 内部不能调用 into_data需要 &mut self
// 但这里 operation 已完成store 仍可用
let ops = std::mem::take(&mut store.data_mut().pending_ops);
(r, ops)
})) {
Ok((r, ops)) => (r, ops),
Err(_) => {
// panic 后丢弃所有 pending_ops避免半完成状态写入数据库
tracing::warn!(plugin = %pid_owned, "WASM panic, discarding pending ops");
(
Err(PluginError::ExecutionError("WASM panic".to_string())),
Vec::new(),
)
}
}
}),
)
.await
.map_err(|_| PluginError::ExecutionError(format!("插件执行超时 ({}s)", timeout_secs)))?
.map_err(|e| PluginError::ExecutionError(e.to_string()))?;
// 更新运行时指标
let elapsed_ms = start.elapsed().as_millis() as f64;
{
let mut metrics = loaded.metrics.write().await;
metrics.total_invocations += 1;
metrics.total_response_ms += elapsed_ms;
metrics.last_invocation_at = Some(chrono::Utc::now());
if result.is_err() {
metrics.error_count += 1;
metrics.last_error = result.as_ref().err().map(|e| e.to_string());
}
}
// 刷新写操作到数据库
Self::flush_ops(
&self.db,
plugin_id,
pending_ops,
exec_ctx.tenant_id,
exec_ctx.user_id,
&self.event_bus,
)
.await?;
result
}
/// 从数据库加载插件配置(通过 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,
}
ConfigRow::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
"SELECT config_json FROM plugins WHERE tenant_id = $1\n\
AND deleted_at IS NULL\n\
AND manifest_json->'metadata'->>'id' = $2\n\
LIMIT 1",
[tenant_id.into(), pid.into()],
))
.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(
manifest: &crate::manifest::PluginManifest,
db: &DatabaseConnection,
tenant_id: Uuid,
) -> HashMap<String, String> {
let mut map = HashMap::new();
let Some(schema) = &manifest.schema else {
return map;
};
for entity in &schema.entities {
for field in &entity.fields {
if let (Some(target_plugin), Some(ref_entity)) =
(&field.ref_plugin, &field.ref_entity)
{
let key = format!("{}.{}", target_plugin, ref_entity);
// 从 plugin_entities 表查找目标表名
let table_name = crate::entity::plugin_entity::Entity::find()
.filter(
crate::entity::plugin_entity::Column::ManifestId
.eq(target_plugin.as_str()),
)
.filter(
crate::entity::plugin_entity::Column::EntityName
.eq(ref_entity.as_str()),
)
.filter(crate::entity::plugin_entity::Column::TenantId.eq(tenant_id))
.filter(crate::entity::plugin_entity::Column::DeletedAt.is_null())
.one(db)
.await
.ok()
.flatten()
.map(|e| e.table_name);
if let Some(tn) = table_name {
map.insert(key, tn);
}
}
}
}
map
}
/// 刷新 HostState 中的 pending_ops 到数据库。
///
/// 使用事务包裹所有数据库操作确保原子性。
/// 事件发布在事务提交后执行best-effort
pub(crate) async fn flush_ops(
db: &DatabaseConnection,
plugin_id: &str,
ops: Vec<PendingOp>,
tenant_id: Uuid,
user_id: Uuid,
event_bus: &EventBus,
) -> PluginResult<()> {
if ops.is_empty() {
return Ok(());
}
// 使用事务确保所有数据库操作的原子性
let txn = db
.begin()
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
for op in &ops {
match op {
PendingOp::Insert { id, entity, data } => {
let table_name = DynamicTableManager::table_name(plugin_id, entity);
let parsed_data: serde_json::Value =
serde_json::from_slice(data).unwrap_or_default();
let id_uuid = id
.parse::<Uuid>()
.map_err(|e| PluginError::ExecutionError(format!("无效的 ID: {}", e)))?;
let (sql, values) = DynamicTableManager::build_insert_sql_with_id(
&table_name,
id_uuid,
tenant_id,
user_id,
&parsed_data,
);
txn.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
tracing::debug!(
plugin_id,
entity = %entity,
"Flushed INSERT op"
);
}
PendingOp::Update {
entity,
id,
data,
version,
} => {
let table_name = DynamicTableManager::table_name(plugin_id, entity);
let parsed_data: serde_json::Value =
serde_json::from_slice(data).unwrap_or_default();
let id_uuid = id
.parse::<Uuid>()
.map_err(|e| PluginError::ExecutionError(format!("无效的 ID: {}", e)))?;
let (sql, values) = DynamicTableManager::build_update_sql(
&table_name,
id_uuid,
tenant_id,
user_id,
&parsed_data,
*version as i32,
);
txn.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
tracing::debug!(
plugin_id,
entity = %entity,
id = %id,
"Flushed UPDATE op"
);
}
PendingOp::Delete { entity, id } => {
let table_name = DynamicTableManager::table_name(plugin_id, entity);
let id_uuid = id
.parse::<Uuid>()
.map_err(|e| PluginError::ExecutionError(format!("无效的 ID: {}", e)))?;
let (sql, values) =
DynamicTableManager::build_delete_sql(&table_name, id_uuid, tenant_id);
txn.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
tracing::debug!(
plugin_id,
entity = %entity,
id = %id,
"Flushed DELETE op"
);
}
PendingOp::PublishEvent { .. } => {
// 事件发布在事务提交后处理
}
}
}
// 提交事务
txn.commit()
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
// 事务提交成功后发布事件best-effort不阻塞主流程
for op in ops {
if let PendingOp::PublishEvent {
event_type,
payload,
} = op
{
let parsed_payload: serde_json::Value =
serde_json::from_slice(&payload).unwrap_or_default();
let event =
erp_core::events::DomainEvent::new(&event_type, tenant_id, parsed_payload);
event_bus.publish(event, db).await;
tracing::debug!(
plugin_id,
event_type = %event_type,
"Flushed PUBLISH_EVENT op"
);
}
}
Ok(())
}
}
/// 插件信息摘要
#[derive(Debug, Clone, serde::Serialize)]
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: String,
}

View File

@@ -0,0 +1,45 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "plugin_market_entries")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub plugin_id: String,
pub name: String,
pub version: String,
pub description: Option<String>,
pub author: Option<String>,
pub category: Option<String>,
pub tags: Option<serde_json::Value>,
pub icon_url: Option<String>,
pub screenshots: Option<serde_json::Value>,
#[serde(skip)]
pub wasm_binary: Vec<u8>,
#[serde(skip_serializing)]
pub manifest_toml: String,
pub wasm_hash: String,
pub min_platform_version: Option<String>,
pub status: String,
pub download_count: i32,
pub rating_avg: Decimal,
pub rating_count: i32,
pub changelog: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::market_review::Entity")]
MarketReview,
}
impl Related<super::market_review::Entity> for Entity {
fn to() -> RelationDef {
Relation::MarketReview.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,33 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "plugin_market_reviews")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub market_entry_id: Uuid,
pub rating: i32,
pub review_text: Option<String>,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::market_entry::Entity",
from = "Column::MarketEntryId",
to = "super::market_entry::Column::Id"
)]
MarketEntry,
}
impl Related<super::market_entry::Entity> for Entity {
fn to() -> RelationDef {
Relation::MarketEntry.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,5 @@
pub mod market_entry;
pub mod market_review;
pub mod plugin;
pub mod plugin_entity;
pub mod plugin_event_subscription;

View File

@@ -0,0 +1,54 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "plugins")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
#[sea_orm(column_name = "plugin_version")]
pub plugin_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
pub status: String,
pub manifest_json: serde_json::Value,
#[serde(skip)]
pub wasm_binary: Vec<u8>,
pub wasm_hash: String,
pub config_json: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub installed_at: Option<DateTimeUtc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_at: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::plugin_entity::Entity")]
PluginEntity,
#[sea_orm(has_many = "super::plugin_event_subscription::Entity")]
PluginEventSubscription,
}
impl Related<super::plugin_entity::Entity> for Entity {
fn to() -> RelationDef {
Relation::PluginEntity.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,43 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "plugin_entities")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub plugin_id: Uuid,
pub entity_name: String,
pub table_name: String,
pub schema_json: serde_json::Value,
pub manifest_id: String,
pub is_public: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::plugin::Entity",
from = "Column::PluginId",
to = "super::plugin::Column::Id"
)]
Plugin,
}
impl Related<super::plugin::Entity> for Entity {
fn to() -> RelationDef {
Relation::Plugin.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,30 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "plugin_event_subscriptions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub plugin_id: Uuid,
pub event_pattern: String,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::plugin::Entity",
from = "Column::PluginId",
to = "super::plugin::Column::Id"
)]
Plugin,
}
impl Related<super::plugin::Entity> for Entity {
fn to() -> RelationDef {
Relation::Plugin.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,55 @@
use erp_core::error::AppError;
/// 插件模块错误类型
#[derive(Debug, thiserror::Error)]
pub enum PluginError {
#[error("插件未找到: {0}")]
NotFound(String),
#[error("插件已存在: {0}")]
AlreadyExists(String),
#[error("无效的插件清单: {0}")]
InvalidManifest(String),
#[error("无效的插件状态: 期望 {expected}, 实际 {actual}")]
InvalidState { expected: String, actual: String },
#[error("插件执行错误: {0}")]
ExecutionError(String),
#[error("插件实例化错误: {0}")]
InstantiationError(String),
#[error("插件 Fuel 耗尽: {0}")]
FuelExhausted(String),
#[error("依赖未满足: {0}")]
DependencyNotSatisfied(String),
#[error("数据库错误: {0}")]
DatabaseError(String),
#[error("权限不足: {0}")]
PermissionDenied(String),
#[error("配置校验失败: {0}")]
ValidationError(String),
}
impl From<PluginError> for AppError {
fn from(err: PluginError) -> Self {
match &err {
PluginError::NotFound(_) => AppError::NotFound(err.to_string()),
PluginError::AlreadyExists(_) => AppError::Conflict(err.to_string()),
PluginError::InvalidManifest(_)
| PluginError::InvalidState { .. }
| PluginError::DependencyNotSatisfied(_)
| PluginError::ValidationError(_) => AppError::Validation(err.to_string()),
PluginError::PermissionDenied(_) => AppError::Forbidden(err.to_string()),
_ => AppError::Internal(err.to_string()),
}
}
}
pub type PluginResult<T> = Result<T, PluginError>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
use axum::Extension;
use axum::extract::{FromRef, Path, Query, State};
use axum::response::Json;
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
prelude::Decimal,
};
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::data_dto::{
MarketEntryDetailResp, MarketEntryResp, MarketListParams, MarketReviewResp, SubmitReviewReq,
};
use crate::entity::{market_entry, market_review, plugin};
use crate::state::PluginState;
fn entry_to_resp(model: &market_entry::Model) -> MarketEntryResp {
MarketEntryResp {
id: model.id.to_string(),
plugin_id: model.plugin_id.clone(),
name: model.name.clone(),
version: model.version.clone(),
description: model.description.clone(),
author: model.author.clone(),
category: model.category.clone(),
tags: model.tags.clone(),
icon_url: model.icon_url.clone(),
screenshots: model.screenshots.clone(),
min_platform_version: model.min_platform_version.clone(),
status: model.status.clone(),
download_count: model.download_count,
rating_avg: model.rating_avg.to_string().parse().unwrap_or(0.0),
rating_count: model.rating_count,
changelog: model.changelog.clone(),
created_at: Some(model.created_at),
updated_at: Some(model.updated_at),
}
}
#[utoipa::path(
get,
path = "/api/v1/market/entries",
params(MarketListParams),
responses(
(status = 200, description = "市场条目列表", body = ApiResponse<PaginatedResponse<MarketEntryResp>>)
),
tag = "Plugin Market",
)]
pub async fn list_market_entries<S>(
State(_state): State<S>,
Query(params): Query<MarketListParams>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<PaginatedResponse<MarketEntryResp>>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let state: PluginState = PluginState::from_ref(&_state);
let db = &state.db;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20).min(100);
let mut query =
market_entry::Entity::find().filter(market_entry::Column::Status.eq("published"));
if let Some(ref category) = params.category {
query = query.filter(market_entry::Column::Category.eq(category.as_str()));
}
if let Some(ref search) = params.search {
query = query.filter(
sea_orm::Condition::any()
.add(market_entry::Column::Name.contains(search.as_str()))
.add(market_entry::Column::Description.contains(search.as_str()))
.add(market_entry::Column::Author.contains(search.as_str())),
);
}
query = query.order_by_desc(market_entry::Column::DownloadCount);
let total = query
.clone()
.count(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
let total_pages = ((total as f64) / (page_size as f64)).ceil() as u64;
let models = query
.paginate(db, page_size)
.fetch_page(page.saturating_sub(1))
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
let items = models.iter().map(entry_to_resp).collect();
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: items,
total,
page,
page_size,
total_pages,
})))
}
#[utoipa::path(
get,
path = "/api/v1/market/entries/{id}",
responses(
(status = 200, description = "市场条目详情", body = ApiResponse<MarketEntryDetailResp>)
),
tag = "Plugin Market",
)]
pub async fn get_market_entry<S>(
State(_state): State<S>,
Path(id): Path<Uuid>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<MarketEntryDetailResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let state: PluginState = PluginState::from_ref(&_state);
let db = &state.db;
let model = market_entry::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?
.ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?;
// 解析 manifest 检查依赖
let mut dependency_warnings = Vec::new();
if let Ok(manifest) = crate::manifest::parse_manifest(&model.manifest_toml) {
for dep_id in &manifest.metadata.dependencies {
let installed = plugin::Entity::find()
.filter(plugin::Column::Name.eq(dep_id.as_str()))
.one(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
if installed.is_none() {
dependency_warnings.push(format!("依赖插件 '{}' 尚未安装", dep_id));
}
}
}
Ok(Json(ApiResponse::ok(MarketEntryDetailResp {
entry: entry_to_resp(&model),
dependency_warnings,
})))
}
#[utoipa::path(
post,
path = "/api/v1/market/entries/{id}/install",
responses(
(status = 200, description = "从市场安装插件", body = ApiResponse<crate::dto::PluginResp>)
),
tag = "Plugin Market",
)]
pub async fn install_from_market<S>(
State(_state): State<S>,
Path(id): Path<Uuid>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<crate::dto::PluginResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let state: PluginState = PluginState::from_ref(&_state);
let db = &state.db;
let engine = &state.engine;
// 获取市场条目
let market_model = market_entry::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?
.ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?;
if market_model.status != "published" {
return Err(AppError::Validation("该插件已下架,无法安装".to_string()));
}
// 检查是否已安装同 plugin_id 的插件
let existing = plugin::Entity::find()
.filter(plugin::Column::Name.eq(market_model.plugin_id.as_str()))
.filter(plugin::Column::TenantId.eq(ctx.tenant_id))
.one(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
if existing.is_some() {
return Err(AppError::Validation(
"该插件已安装,如需更新请使用升级功能".to_string(),
));
}
// upload → install → enable 一条龙
let wasm_binary = market_model.wasm_binary.clone();
let manifest_toml = market_model.manifest_toml.clone();
let plugin_resp = crate::service::PluginService::upload(
ctx.tenant_id,
ctx.user_id,
wasm_binary,
&manifest_toml,
db,
)
.await?;
let plugin_id = plugin_resp.id;
let _plugin_resp =
crate::service::PluginService::install(plugin_id, ctx.tenant_id, ctx.user_id, db, engine)
.await?;
let plugin_resp =
crate::service::PluginService::enable(plugin_id, ctx.tenant_id, ctx.user_id, db, engine)
.await?;
// 递增下载计数
let mut active: market_entry::ActiveModel = market_model.into();
let current = active.download_count.take().unwrap_or(0);
active.download_count = Set(current + 1);
let _ = active
.update(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(ApiResponse::ok(plugin_resp)))
}
#[utoipa::path(
get,
path = "/api/v1/market/entries/{id}/reviews",
responses(
(status = 200, description = "评论列表", body = ApiResponse<Vec<MarketReviewResp>>)
),
tag = "Plugin Market",
)]
pub async fn list_market_reviews<S>(
State(_state): State<S>,
Path(id): Path<Uuid>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<MarketReviewResp>>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let state: PluginState = PluginState::from_ref(&_state);
let db = &state.db;
let reviews = market_review::Entity::find()
.filter(market_review::Column::MarketEntryId.eq(id))
.all(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
let items = reviews
.iter()
.map(|r| MarketReviewResp {
id: r.id.to_string(),
user_id: r.user_id.to_string(),
market_entry_id: r.market_entry_id.to_string(),
rating: r.rating,
review_text: r.review_text.clone(),
created_at: Some(r.created_at),
})
.collect();
Ok(Json(ApiResponse::ok(items)))
}
#[utoipa::path(
post,
path = "/api/v1/market/entries/{id}/reviews",
responses(
(status = 200, description = "提交评分/评论", body = ApiResponse<MarketReviewResp>)
),
tag = "Plugin Market",
)]
pub async fn submit_market_review<S>(
State(_state): State<S>,
Path(id): Path<Uuid>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<SubmitReviewReq>,
) -> Result<Json<ApiResponse<MarketReviewResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
if body.rating < 1 || body.rating > 5 {
return Err(AppError::Validation("评分必须在 1-5 之间".to_string()));
}
let state: PluginState = PluginState::from_ref(&_state);
let db = &state.db;
// 验证市场条目存在
let entry_model = market_entry::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?
.ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?;
// upsert: 同一用户同一条目只保留最新评论
let existing = market_review::Entity::find()
.filter(market_review::Column::MarketEntryId.eq(id))
.filter(market_review::Column::UserId.eq(ctx.user_id))
.filter(market_review::Column::TenantId.eq(ctx.tenant_id))
.one(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
let review_model = if let Some(existing) = existing {
let mut active: market_review::ActiveModel = existing.into();
active.rating = Set(body.rating);
active.review_text = Set(body.review_text);
active
.update(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?
} else {
let review_id = Uuid::now_v7();
let now = Utc::now();
let model = market_review::ActiveModel {
id: Set(review_id),
tenant_id: Set(ctx.tenant_id),
user_id: Set(ctx.user_id),
market_entry_id: Set(id),
rating: Set(body.rating),
review_text: Set(body.review_text),
created_at: Set(now),
};
model
.insert(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?
};
// 重新计算平均评分
let all_reviews = market_review::Entity::find()
.filter(market_review::Column::MarketEntryId.eq(id))
.all(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
let count = all_reviews.len() as i32;
let avg: f64 = if count > 0 {
all_reviews.iter().map(|r| r.rating as f64).sum::<f64>() / count as f64
} else {
0.0
};
let mut entry_active: market_entry::ActiveModel = entry_model.into();
let avg_decimal = Decimal::from_f64_retain(avg).unwrap_or_default();
entry_active.rating_avg = Set(avg_decimal);
entry_active.rating_count = Set(count);
let _ = entry_active
.update(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(ApiResponse::ok(MarketReviewResp {
id: review_model.id.to_string(),
user_id: review_model.user_id.to_string(),
market_entry_id: review_model.market_entry_id.to_string(),
rating: review_model.rating,
review_text: review_model.review_text,
created_at: Some(review_model.created_at),
})))
}

View File

@@ -0,0 +1,3 @@
pub mod data_handler;
pub mod market_handler;
pub mod plugin_handler;

View File

@@ -0,0 +1,510 @@
use axum::Extension;
use axum::extract::{FromRef, Multipart, Path, Query, State};
use axum::response::Json;
use uuid::Uuid;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
use crate::dto::{PluginHealthResp, PluginListParams, PluginResp, UpdatePluginConfigReq};
use crate::service::PluginService;
use crate::state::PluginState;
#[utoipa::path(
post,
path = "/api/v1/admin/plugins/upload",
request_body(content_type = "multipart/form-data"),
responses(
(status = 200, description = "上传成功", body = ApiResponse<PluginResp>),
(status = 401, description = "未授权"),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// POST /api/v1/admin/plugins/upload — 上传插件 (multipart: wasm + manifest)
pub async fn upload_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let mut wasm_binary: Option<Vec<u8>> = None;
let mut manifest_toml: Option<String> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::Validation(format!("Multipart 解析失败: {}", e)))?
{
let name = field.name().unwrap_or("");
match name {
"wasm" => {
wasm_binary = Some(
field
.bytes()
.await
.map_err(|e| AppError::Validation(format!("读取 WASM 文件失败: {}", e)))?
.to_vec(),
);
}
"manifest" => {
let bytes = field
.bytes()
.await
.map_err(|e| AppError::Validation(format!("读取 Manifest 失败: {}", e)))?;
let text = String::from_utf8(bytes.to_vec()).map_err(|e| {
AppError::Validation(format!("Manifest 不是有效的 UTF-8: {}", e))
})?;
manifest_toml = Some(text);
}
_ => {}
}
}
let wasm = wasm_binary.ok_or_else(|| AppError::Validation("缺少 wasm 文件".to_string()))?;
let manifest =
manifest_toml.ok_or_else(|| AppError::Validation("缺少 manifest 文件".to_string()))?;
let result =
PluginService::upload(ctx.tenant_id, ctx.user_id, wasm, &manifest, &state.db).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
get,
path = "/api/v1/admin/plugins",
params(PluginListParams),
responses(
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<PluginResp>>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// GET /api/v1/admin/plugins — 列表
pub async fn list_plugins<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<PluginListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PluginResp>>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.list")?;
let pagination = Pagination {
page: params.page,
page_size: params.page_size,
};
let (plugins, total) = PluginService::list(
ctx.tenant_id,
pagination.page.unwrap_or(1),
pagination.page_size.unwrap_or(20),
params.status.as_deref(),
params.search.as_deref(),
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: plugins,
total,
page: pagination.page.unwrap_or(1),
page_size: pagination.page_size.unwrap_or(20),
total_pages: (total as f64 / pagination.page_size.unwrap_or(20) as f64).ceil() as u64,
})))
}
#[utoipa::path(
get,
path = "/api/v1/admin/plugins/{id}",
responses(
(status = 200, description = "成功", body = ApiResponse<PluginResp>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// GET /api/v1/admin/plugins/{id} — 详情
pub async fn get_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.list")?;
let result = PluginService::get_by_id(id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
get,
path = "/api/v1/admin/plugins/{id}/schema",
responses(
(status = 200, description = "成功"),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// GET /api/v1/admin/plugins/{id}/schema — 实体 schema
pub async fn get_plugin_schema<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.list")?;
let schema = PluginService::get_schema(id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(schema)))
}
#[utoipa::path(
post,
path = "/api/v1/admin/plugins/{id}/install",
responses(
(status = 200, description = "安装成功", body = ApiResponse<PluginResp>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// POST /api/v1/admin/plugins/{id}/install — 安装
pub async fn install_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let result = PluginService::install(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine)
.await
.map_err(|e| {
tracing::error!(error = %e, "Install failed");
e
})?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
post,
path = "/api/v1/admin/plugins/{id}/enable",
responses(
(status = 200, description = "启用成功", body = ApiResponse<PluginResp>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// POST /api/v1/admin/plugins/{id}/enable — 启用
pub async fn enable_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let result =
PluginService::enable(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
post,
path = "/api/v1/admin/plugins/{id}/disable",
responses(
(status = 200, description = "停用成功", body = ApiResponse<PluginResp>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// POST /api/v1/admin/plugins/{id}/disable — 停用
pub async fn disable_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let result =
PluginService::disable(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
post,
path = "/api/v1/admin/plugins/{id}/uninstall",
responses(
(status = 200, description = "卸载成功", body = ApiResponse<PluginResp>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// POST /api/v1/admin/plugins/{id}/uninstall — 卸载
pub async fn uninstall_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let result =
PluginService::uninstall(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
delete,
path = "/api/v1/admin/plugins/{id}",
responses(
(status = 200, description = "清除成功"),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// DELETE /api/v1/admin/plugins/{id} — 清除(软删除)
pub async fn purge_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
PluginService::purge(id, ctx.tenant_id, ctx.user_id, &state.db).await?;
Ok(Json(ApiResponse::ok(())))
}
#[utoipa::path(
get,
path = "/api/v1/admin/plugins/{id}/health",
responses(
(status = 200, description = "健康检查", body = ApiResponse<PluginHealthResp>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// GET /api/v1/admin/plugins/{id}/health — 健康检查
pub async fn health_check_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<PluginHealthResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.list")?;
let result = PluginService::health_check(id, ctx.tenant_id, &state.db, &state.engine).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
get,
path = "/api/v1/admin/plugins/{id}/metrics",
responses(
(status = 200, description = "运行时指标", body = ApiResponse<serde_json::Value>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// GET /api/v1/admin/plugins/{id}/metrics — 运行时指标
pub async fn get_plugin_metrics<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.list")?;
// 通过 plugin_id 找到 manifest_id再查询 metrics
let manifest_id =
crate::data_service::resolve_manifest_id(id, ctx.tenant_id, &state.db).await?;
let metrics = state.engine.get_metrics(&manifest_id).await?;
let avg_ms = if metrics.total_invocations > 0 {
metrics.total_response_ms / metrics.total_invocations as f64
} else {
0.0
};
Ok(Json(ApiResponse::ok(serde_json::json!({
"plugin_id": manifest_id,
"total_invocations": metrics.total_invocations,
"error_count": metrics.error_count,
"avg_response_ms": avg_ms,
"last_error": metrics.last_error,
"last_invocation_at": metrics.last_invocation_at,
}))))
}
#[utoipa::path(
put,
path = "/api/v1/admin/plugins/{id}/config",
request_body = UpdatePluginConfigReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<PluginResp>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// PUT /api/v1/admin/plugins/{id}/config — 更新配置
pub async fn update_plugin_config<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdatePluginConfigReq>,
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let result = PluginService::update_config(
id,
ctx.tenant_id,
ctx.user_id,
req.config,
req.version,
&state.db,
Some(&state.event_bus),
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
post,
path = "/api/v1/admin/plugins/{id}/upgrade",
request_body(content_type = "multipart/form-data"),
responses(
(status = 200, description = "升级成功", body = ApiResponse<PluginResp>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// POST /api/v1/admin/plugins/{id}/upgrade — 热更新插件
///
/// 上传新版本 WASM + manifest对比 schema 变更,执行增量 DDL
/// 更新插件记录。失败时保持旧版本继续运行。
pub async fn upgrade_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let mut wasm_binary: Option<Vec<u8>> = None;
let mut manifest_toml: Option<String> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::Validation(format!("Multipart 解析失败: {}", e)))?
{
let name = field.name().unwrap_or("");
match name {
"wasm" => {
wasm_binary = Some(
field
.bytes()
.await
.map_err(|e| AppError::Validation(format!("读取 WASM 文件失败: {}", e)))?
.to_vec(),
);
}
"manifest" => {
let bytes = field
.bytes()
.await
.map_err(|e| AppError::Validation(format!("读取 Manifest 失败: {}", e)))?;
manifest_toml = Some(String::from_utf8(bytes.to_vec()).map_err(|e| {
AppError::Validation(format!("Manifest 不是有效的 UTF-8: {}", e))
})?);
}
_ => {}
}
}
let wasm = wasm_binary.ok_or_else(|| AppError::Validation("缺少 wasm 文件".to_string()))?;
let manifest =
manifest_toml.ok_or_else(|| AppError::Validation("缺少 manifest 文件".to_string()))?;
let result = PluginService::upgrade(
id,
ctx.tenant_id,
ctx.user_id,
wasm,
&manifest,
&state.db,
&state.engine,
)
.await?;
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

@@ -0,0 +1,438 @@
use std::collections::HashMap;
use sea_orm::DatabaseConnection;
use uuid::Uuid;
use wasmtime::StoreLimits;
use crate::dynamic_table::DynamicTableManager;
use crate::engine::PluginEngine;
use crate::erp::plugin::host_api;
/// 待刷新的写操作
#[derive(Debug)]
pub enum PendingOp {
Insert {
id: String,
entity: String,
data: Vec<u8>,
},
Update {
entity: String,
id: String,
data: Vec<u8>,
version: i64,
},
Delete {
entity: String,
id: String,
},
PublishEvent {
event_type: String,
payload: Vec<u8>,
},
}
/// Host 端状态 — 绑定到每个 WASM Store 实例
///
/// 支持两种执行模式:
/// - **预填充模式**db = None读操作从预填充缓存取向后兼容
/// - **混合执行模式**db = Some读操作走实时 SQL + 写操作保持延迟批量
pub struct HostState {
pub(crate) limits: StoreLimits,
#[allow(dead_code)]
pub(crate) tenant_id: Uuid,
#[allow(dead_code)]
pub(crate) user_id: Uuid,
pub(crate) permissions: Vec<String>,
pub(crate) plugin_id: String,
// 预填充的读取缓存(向后兼容)
pub(crate) query_results: HashMap<String, Vec<u8>>,
pub(crate) config_cache: HashMap<String, Vec<u8>>,
pub(crate) current_user_json: Vec<u8>,
// 待刷新的写操作
pub(crate) pending_ops: Vec<PendingOp>,
// 日志
pub(crate) logs: Vec<(String, String)>,
// 混合执行模式:数据库连接和事件总线
pub(crate) db: Option<DatabaseConnection>,
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 {
pub fn new(
plugin_id: String,
tenant_id: Uuid,
user_id: Uuid,
permissions: Vec<String>,
) -> Self {
let current_user = serde_json::json!({
"id": user_id.to_string(),
"tenant_id": tenant_id.to_string(),
});
Self {
limits: wasmtime::StoreLimitsBuilder::new().build(),
tenant_id,
user_id,
permissions,
plugin_id,
query_results: HashMap::new(),
config_cache: HashMap::new(),
current_user_json: serde_json::to_vec(&current_user).unwrap_or_default(),
pending_ops: Vec::new(),
logs: Vec::new(),
db: None,
event_bus: None,
cross_plugin_entities: HashMap::new(),
numbering_rules: HashMap::new(),
plugin_config: serde_json::json!({}),
}
}
/// 创建带数据库连接的 HostState混合执行模式
pub fn new_with_db(
plugin_id: String,
tenant_id: Uuid,
user_id: Uuid,
permissions: Vec<String>,
db: DatabaseConnection,
event_bus: erp_core::events::EventBus,
) -> Self {
let mut state = Self::new(plugin_id, tenant_id, user_id, permissions);
state.db = Some(db);
state.event_bus = Some(event_bus);
state
}
}
// 实现 bindgen 生成的 Host trait — 插件调用 Host API 的入口
impl host_api::Host for HostState {
fn db_insert(&mut self, entity: String, data: Vec<u8>) -> Result<Vec<u8>, String> {
let id = Uuid::now_v7().to_string();
let response = serde_json::json!({
"id": id,
"entity": entity,
"status": "queued",
});
self.pending_ops.push(PendingOp::Insert {
id: id.clone(),
entity,
data,
});
serde_json::to_vec(&response).map_err(|e| e.to_string())
}
fn db_query(
&mut self,
entity: String,
filter: Vec<u8>,
pagination: Vec<u8>,
) -> Result<Vec<u8>, String> {
// 预填充模式(向后兼容)
if self.db.is_none() {
return self
.query_results
.get(&entity)
.cloned()
.ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity));
}
let db = self.db.clone().ok_or("数据库连接不可用")?;
let event_bus = self.event_bus.clone().ok_or("事件总线不可用")?;
// 先 flush pending writes确保读后写一致性
let ops = std::mem::take(&mut self.pending_ops);
if !ops.is_empty() {
let rt = tokio::runtime::Handle::current();
rt.block_on(PluginEngine::flush_ops(
&db,
&self.plugin_id,
ops,
self.tenant_id,
self.user_id,
&event_bus,
))
.map_err(|e| format!("flush pending ops 失败: {}", e))?;
}
// 解析 filter 和 pagination
let filter_val: Option<serde_json::Value> = if filter.is_empty() {
None
} else {
serde_json::from_slice(&filter).ok()
};
let pagination_val: Option<serde_json::Value> = if pagination.is_empty() {
None
} else {
serde_json::from_slice(&pagination).ok()
};
// 构建查询 — 支持点分记号跨插件查询(如 "erp-crm.customer"
let table_name = if entity.contains('.') {
self.cross_plugin_entities
.get(&entity)
.cloned()
.ok_or_else(|| format!("跨插件实体 '{}' 未注册", entity))?
} else {
DynamicTableManager::table_name(&self.plugin_id, &entity)
};
let limit = pagination_val
.as_ref()
.and_then(|p| p.get("limit"))
.and_then(|v| v.as_u64())
.unwrap_or(50);
let offset = pagination_val
.as_ref()
.and_then(|p| p.get("offset"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
&table_name,
self.tenant_id,
limit,
offset,
filter_val,
None,
None,
None,
)
.map_err(|e| format!("查询构建失败: {}", e))?;
// 执行查询
let rt = tokio::runtime::Handle::current();
let rows = rt
.block_on(async {
use sea_orm::{FromQueryResult, Statement};
#[derive(Debug, FromQueryResult)]
struct QueryRow {
data: serde_json::Value,
}
let results = QueryRow::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.all(&db)
.await
.map_err(|e| format!("查询执行失败: {}", e))?;
let items: Vec<serde_json::Value> = results.into_iter().map(|r| r.data).collect();
Ok::<Vec<serde_json::Value>, String>(items)
})
.map_err(|e: String| e)?;
serde_json::to_vec(&rows).map_err(|e| e.to_string())
}
fn db_update(
&mut self,
entity: String,
id: String,
data: Vec<u8>,
version: i64,
) -> Result<Vec<u8>, String> {
let response = serde_json::json!({
"id": id,
"entity": entity,
"version": version + 1,
"status": "queued",
});
self.pending_ops.push(PendingOp::Update {
entity,
id,
data,
version,
});
serde_json::to_vec(&response).map_err(|e| e.to_string())
}
fn db_delete(&mut self, entity: String, id: String) -> Result<(), String> {
self.pending_ops.push(PendingOp::Delete { entity, id });
Ok(())
}
fn event_publish(&mut self, event_type: String, payload: Vec<u8>) -> Result<(), String> {
self.pending_ops.push(PendingOp::PublishEvent {
event_type,
payload,
});
Ok(())
}
fn config_get(&mut self, key: String) -> Result<Vec<u8>, String> {
self.config_cache
.get(&key)
.cloned()
.ok_or_else(|| format!("配置项 '{}' 未预填充", key))
}
fn log_write(&mut self, level: String, message: String) {
tracing::info!(
plugin = %self.plugin_id,
level = %level,
"Plugin log: {}",
message
);
self.logs.push((level, message));
}
fn current_user(&mut self) -> Result<Vec<u8>, String> {
Ok(self.current_user_json.clone())
}
fn check_permission(&mut self, permission: String) -> Result<bool, String> {
Ok(self.permissions.contains(&permission))
}
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))?
.clone();
let db = self.db.clone().ok_or("编号生成需要数据库连接")?;
let _tenant_id = self.tenant_id;
let plugin_id = self.plugin_id.clone();
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
use sea_orm::{ConnectionTrait, FromQueryResult, Statement};
let now = chrono::Utc::now();
let year = now.format("%Y").to_string();
let month = now.format("%m").to_string();
let day = now.format("%d").to_string();
// 计算当前周期的 key用于 reset_rule 判断)
let period_key = match rule.reset_rule.as_str() {
"daily" => format!("{}-{}-{}", year, month, day),
"monthly" => format!("{}-{}", year, month),
"yearly" => year.clone(),
_ => String::new(), // "never" — 不需要周期 key
};
// 序列表名(使用 sanitize_identifier 防注入)
let table_name = format!(
"plugin_numbering_seq_{}",
crate::dynamic_table::sanitize_identifier(&plugin_id)
);
// 确保序列表存在
let create_sql = format!(
"CREATE TABLE IF NOT EXISTS {} (\
rule_key VARCHAR(255) NOT NULL, \
period_key VARCHAR(64) NOT NULL DEFAULT '', \
current_val BIGINT NOT NULL DEFAULT 0, \
PRIMARY KEY (rule_key, period_key)\
)",
table_name
);
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
create_sql,
))
.await
.map_err(|e| format!("创建序列表失败: {}", e))?;
// 使用 advisory lock 保证并发安全
// lock_id 基于规则名哈希
let lock_id: i64 = {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
(plugin_id.clone() + &rule_key).hash(&mut hasher);
(hasher.finish() as i64).abs()
};
let lock_sql = format!("SELECT pg_advisory_xact_lock({})", lock_id);
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
lock_sql,
))
.await
.map_err(|e| format!("获取锁失败: {}", e))?;
// 读取当前值
#[derive(Debug, FromQueryResult)]
struct SeqRow {
current_val: i64,
}
let read_sql = format!(
"SELECT current_val FROM {} WHERE rule_key = $1 AND period_key = $2",
table_name
);
let current = SeqRow::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
read_sql,
[rule_key.clone().into(), period_key.clone().into()],
))
.one(&db)
.await
.map_err(|e| format!("读取序列失败: {}", e))?;
let next_val = current.map(|r| r.current_val + 1).unwrap_or(1);
// UPSERT 新值
let upsert_sql = format!(
"INSERT INTO {} (rule_key, period_key, current_val) VALUES ($1, $2, $3) \
ON CONFLICT (rule_key, period_key) DO UPDATE SET current_val = $3",
table_name
);
db.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
upsert_sql,
[
rule_key.clone().into(),
period_key.clone().into(),
next_val.into(),
],
))
.await
.map_err(|e| format!("更新序列失败: {}", e))?;
let seq_str = format!("{:0>width$}", next_val, width = rule.seq_length as usize);
let number = rule
.format
.replace("{PREFIX}", &rule.prefix)
.replace("{YEAR}", &year)
.replace("{MONTH}", &month)
.replace("{DAY}", &day)
.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

@@ -0,0 +1,26 @@
//! ERP WASM 插件运行时 — 生产级 Host API
//!
//! 完整插件管理链路:加载 → 初始化 → 运行 → 停用 → 卸载
// bindgen! 生成类型化绑定(包含 Host trait 和 PluginWorld 类型)
// 生成: erp::plugin::host_api::Host trait, PluginWorld 类型
wasmtime::component::bindgen!({
path: "wit/plugin.wit",
world: "plugin-world",
});
pub mod data_dto;
pub mod data_service;
pub mod dto;
pub mod dynamic_table;
pub mod engine;
pub mod entity;
pub mod error;
pub mod handler;
pub mod host;
pub mod manifest;
pub mod module;
pub mod notification;
pub mod plugin_validator;
pub mod service;
pub mod state;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
use async_trait::async_trait;
use axum::Router;
use axum::routing::{delete, get, post, put};
use erp_core::module::{ErpModule, PermissionDescriptor};
pub struct PluginModule;
#[async_trait]
impl ErpModule for PluginModule {
fn name(&self) -> &str {
"plugin"
}
fn dependencies(&self) -> Vec<&str> {
vec!["auth", "config"]
}
fn permissions(&self) -> Vec<PermissionDescriptor> {
vec![
PermissionDescriptor {
code: "plugin.admin".into(),
name: "插件管理".into(),
description: "管理插件全生命周期".into(),
module: "plugin".into(),
},
PermissionDescriptor {
code: "plugin.list".into(),
name: "查看插件".into(),
description: "查看插件列表".into(),
module: "plugin".into(),
},
]
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl PluginModule {
/// 插件管理路由(需要 JWT 认证)
pub fn protected_routes<S>() -> Router<S>
where
crate::state::PluginState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let admin_routes = Router::new()
.route(
"/admin/plugins/upload",
post(crate::handler::plugin_handler::upload_plugin::<S>),
)
.route(
"/admin/plugins",
get(crate::handler::plugin_handler::list_plugins::<S>),
)
.route(
"/admin/plugins/{id}",
get(crate::handler::plugin_handler::get_plugin::<S>)
.delete(crate::handler::plugin_handler::purge_plugin::<S>),
)
.route(
"/admin/plugins/{id}/schema",
get(crate::handler::plugin_handler::get_plugin_schema::<S>),
)
.route(
"/admin/plugins/{id}/install",
post(crate::handler::plugin_handler::install_plugin::<S>),
)
.route(
"/admin/plugins/{id}/enable",
post(crate::handler::plugin_handler::enable_plugin::<S>),
)
.route(
"/admin/plugins/{id}/disable",
post(crate::handler::plugin_handler::disable_plugin::<S>),
)
.route(
"/admin/plugins/{id}/uninstall",
post(crate::handler::plugin_handler::uninstall_plugin::<S>),
)
.route(
"/admin/plugins/{id}/health",
get(crate::handler::plugin_handler::health_check_plugin::<S>),
)
.route(
"/admin/plugins/{id}/metrics",
get(crate::handler::plugin_handler::get_plugin_metrics::<S>),
)
.route(
"/admin/plugins/{id}/config",
put(crate::handler::plugin_handler::update_plugin_config::<S>),
)
.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 路由
let data_routes = Router::new()
.route(
"/plugins/{plugin_id}/{entity}",
get(crate::handler::data_handler::list_plugin_data::<S>)
.post(crate::handler::data_handler::create_plugin_data::<S>),
)
.route(
"/plugins/{plugin_id}/{entity}/{id}",
get(crate::handler::data_handler::get_plugin_data::<S>)
.put(crate::handler::data_handler::update_plugin_data::<S>)
.patch(crate::handler::data_handler::patch_plugin_data::<S>)
.delete(crate::handler::data_handler::delete_plugin_data::<S>),
)
// 数据统计路由
.route(
"/plugins/{plugin_id}/{entity}/count",
get(crate::handler::data_handler::count_plugin_data::<S>),
)
.route(
"/plugins/{plugin_id}/{entity}/aggregate",
get(crate::handler::data_handler::aggregate_plugin_data::<S>),
)
.route(
"/plugins/{plugin_id}/{entity}/aggregate-multi",
post(crate::handler::data_handler::aggregate_multi_plugin_data::<S>),
)
// 批量操作路由
.route(
"/plugins/{plugin_id}/{entity}/batch",
post(crate::handler::data_handler::batch_plugin_data::<S>),
)
// 时间序列路由
.route(
"/plugins/{plugin_id}/{entity}/timeseries",
get(crate::handler::data_handler::get_plugin_timeseries::<S>),
)
// 跨插件引用:批量标签解析
.route(
"/plugins/{plugin_id}/{entity}/resolve-labels",
post(crate::handler::data_handler::resolve_ref_labels::<S>),
)
// 数据导入导出
.route(
"/plugins/{plugin_id}/{entity}/export",
get(crate::handler::data_handler::export_plugin_data::<S>),
)
.route(
"/plugins/{plugin_id}/{entity}/import",
post(crate::handler::data_handler::import_plugin_data::<S>),
)
// 对账扫描
.route(
"/plugins/{plugin_id}/reconcile",
post(crate::handler::data_handler::reconcile_refs::<S>),
)
// 用户自定义视图
.route(
"/plugins/{plugin_id}/{entity}/views",
get(crate::handler::data_handler::list_user_views::<S>)
.post(crate::handler::data_handler::create_user_view::<S>),
)
.route(
"/plugins/{plugin_id}/{entity}/views/{view_id}",
delete(crate::handler::data_handler::delete_user_view::<S>),
);
// 实体注册表路由
let registry_routes = Router::new().route(
"/plugin-registry/entities",
get(crate::handler::data_handler::list_public_entities::<S>),
);
// 市场路由
let market_routes = Router::new()
.route(
"/market/entries",
get(crate::handler::market_handler::list_market_entries::<S>),
)
.route(
"/market/entries/{id}",
get(crate::handler::market_handler::get_market_entry::<S>),
)
.route(
"/market/entries/{id}/install",
post(crate::handler::market_handler::install_from_market::<S>),
)
.route(
"/market/entries/{id}/reviews",
get(crate::handler::market_handler::list_market_reviews::<S>)
.post(crate::handler::market_handler::submit_market_review::<S>),
);
admin_routes
.merge(data_routes)
.merge(registry_routes)
.merge(market_routes)
}
}

View File

@@ -0,0 +1,109 @@
use chrono::Utc;
use sea_orm::{ConnectionTrait, FromQueryResult, Statement};
use uuid::Uuid;
use erp_core::error::AppResult;
use erp_core::events::{DomainEvent, EventBus};
/// 启动插件通知监听器 — 订阅 plugin.trigger.* 事件
pub fn start_notification_listener(db: sea_orm::DatabaseConnection, event_bus: EventBus) {
let (mut rx, _handle) = event_bus.subscribe_filtered("plugin.trigger.".to_string());
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
if let Err(e) = handle_trigger_event(&event, &db).await {
tracing::warn!(
event_type = %event.event_type,
error = %e,
"Failed to handle plugin trigger notification"
);
}
}
tracing::info!("Plugin notification listener stopped");
});
}
async fn handle_trigger_event(
event: &DomainEvent,
db: &sea_orm::DatabaseConnection,
) -> AppResult<()> {
let plugin_id = event
.payload
.get("plugin_id")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let trigger_name = event
.payload
.get("trigger_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let entity = event
.payload
.get("entity")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let action = event
.payload
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let title = format!("插件事件: {}.{}", plugin_id, trigger_name);
let body = format!(
"插件 [{}] 的实体 [{}] 触发了 [{}] 事件",
plugin_id, entity, action
);
// 查询所有管理员用户
#[derive(FromQueryResult)]
struct AdminUser {
id: Uuid,
}
let admins = AdminUser::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
r#"SELECT u.id FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE u.tenant_id = $1 AND r.name = 'admin' AND u.deleted_at IS NULL"#,
[event.tenant_id.into()],
))
.all(db)
.await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
// 为每个管理员插入消息记录
let now = Utc::now();
for admin in &admins {
let msg_id = Uuid::now_v7();
let sql = r#"
INSERT INTO messages (id, tenant_id, sender_type, recipient_id, recipient_type,
title, body, priority, is_read, created_at, updated_at, version)
VALUES ($1, $2, 'system', $3, 'user', $4, $5, 'normal', false, $6, $7, 1)
"#;
db.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[
msg_id.into(),
event.tenant_id.into(),
admin.id.into(),
title.clone().into(),
body.clone().into(),
now.into(),
now.into(),
],
))
.await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
}
tracing::info!(
plugin_id = %plugin_id,
trigger = %trigger_name,
admin_count = admins.len(),
"Plugin trigger notification sent"
);
Ok(())
}

View File

@@ -0,0 +1,317 @@
use crate::error::PluginResult;
use crate::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().is_some_and(|n| !n.is_empty());
metrics.has_trigger_events = manifest
.trigger_events
.as_ref()
.is_some_and(|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::*;
use crate::manifest::parse_manifest;
#[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());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
use std::time::Duration;
use moka::sync::Cache;
use sea_orm::DatabaseConnection;
use erp_core::error::{AppError, AppResult};
use erp_core::events::EventBus;
use crate::engine::PluginEngine;
/// 插件模块共享状态 — 用于 Axum State 提取
#[derive(Clone)]
pub struct PluginState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub engine: PluginEngine,
/// Schema 缓存 — key: "{plugin_id}:{entity_name}:{tenant_id}"
pub entity_cache: Cache<String, EntityInfo>,
}
/// 缓存的实体信息
#[derive(Clone, Debug)]
pub struct EntityInfo {
pub table_name: String,
pub schema_json: serde_json::Value,
pub generated_fields: Vec<String>,
}
impl EntityInfo {
/// 从 schema_json 解析字段列表
pub fn fields(&self) -> AppResult<Vec<crate::manifest::PluginField>> {
let entity_def: crate::manifest::PluginEntity =
serde_json::from_value(self.schema_json.clone())
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
Ok(entity_def.fields)
}
}
impl PluginState {
pub fn new(db: DatabaseConnection, event_bus: EventBus, engine: PluginEngine) -> Self {
let entity_cache = Cache::builder()
.max_capacity(1000)
.time_to_idle(Duration::from_secs(300))
.build();
Self {
db,
event_bus,
engine,
entity_cache,
}
}
}

View File

@@ -0,0 +1,54 @@
package erp:plugin;
/// 宿主暴露给插件的 API插件 import 这些函数)
interface host-api {
/// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段)
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
/// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤)
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
/// 更新记录(自动检查 version 乐观锁)
db-update: func(entity: string, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
/// 软删除记录
db-delete: func(entity: string, id: string) -> result<_, string>;
/// 发布领域事件
event-publish: func(event-type: string, payload: list<u8>) -> result<_, string>;
/// 读取系统配置
config-get: func(key: string) -> result<list<u8>, string>;
/// 写日志(自动关联 tenant_id + plugin_id
log-write: func(level: string, message: string);
/// 获取当前用户信息
current-user: func() -> result<list<u8>, string>;
/// 检查当前用户权限
check-permission: func(permission: string) -> result<bool, string>;
/// 根据编号规则生成下一个编号(如 INV-2026-0001
numbering-generate: func(rule-key: string) -> result<string, string>;
/// 读取插件配置项
setting-get: func(key: string) -> result<list<u8>, string>;
}
/// 插件导出的 API宿主调用这些函数
interface plugin-api {
/// 插件初始化(加载时调用一次)
init: func() -> result<_, string>;
/// 租户创建时调用
on-tenant-created: func(tenant-id: string) -> result<_, string>;
/// 处理订阅的事件
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
}
world plugin-world {
import host-api;
export plugin-api;
}