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:
30
crates/erp-plugin/Cargo.toml
Normal file
30
crates/erp-plugin/Cargo.toml
Normal 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 }
|
||||
331
crates/erp-plugin/src/data_dto.rs
Normal file
331
crates/erp-plugin/src/data_dto.rs
Normal 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>>,
|
||||
}
|
||||
1907
crates/erp-plugin/src/data_service.rs
Normal file
1907
crates/erp-plugin/src/data_service.rs
Normal file
File diff suppressed because it is too large
Load Diff
68
crates/erp-plugin/src/dto.rs
Normal file
68
crates/erp-plugin/src/dto.rs
Normal 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>,
|
||||
}
|
||||
1759
crates/erp-plugin/src/dynamic_table.rs
Normal file
1759
crates/erp-plugin/src/dynamic_table.rs
Normal file
File diff suppressed because it is too large
Load Diff
875
crates/erp-plugin/src/engine.rs
Normal file
875
crates/erp-plugin/src/engine.rs
Normal 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,
|
||||
}
|
||||
45
crates/erp-plugin/src/entity/market_entry.rs
Normal file
45
crates/erp-plugin/src/entity/market_entry.rs
Normal 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 {}
|
||||
33
crates/erp-plugin/src/entity/market_review.rs
Normal file
33
crates/erp-plugin/src/entity/market_review.rs
Normal 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 {}
|
||||
5
crates/erp-plugin/src/entity/mod.rs
Normal file
5
crates/erp-plugin/src/entity/mod.rs
Normal 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;
|
||||
54
crates/erp-plugin/src/entity/plugin.rs
Normal file
54
crates/erp-plugin/src/entity/plugin.rs
Normal 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 {}
|
||||
43
crates/erp-plugin/src/entity/plugin_entity.rs
Normal file
43
crates/erp-plugin/src/entity/plugin_entity.rs
Normal 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 {}
|
||||
30
crates/erp-plugin/src/entity/plugin_event_subscription.rs
Normal file
30
crates/erp-plugin/src/entity/plugin_event_subscription.rs
Normal 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 {}
|
||||
55
crates/erp-plugin/src/error.rs
Normal file
55
crates/erp-plugin/src/error.rs
Normal 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>;
|
||||
1121
crates/erp-plugin/src/handler/data_handler.rs
Normal file
1121
crates/erp-plugin/src/handler/data_handler.rs
Normal file
File diff suppressed because it is too large
Load Diff
386
crates/erp-plugin/src/handler/market_handler.rs
Normal file
386
crates/erp-plugin/src/handler/market_handler.rs
Normal 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),
|
||||
})))
|
||||
}
|
||||
3
crates/erp-plugin/src/handler/mod.rs
Normal file
3
crates/erp-plugin/src/handler/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod data_handler;
|
||||
pub mod market_handler;
|
||||
pub mod plugin_handler;
|
||||
510
crates/erp-plugin/src/handler/plugin_handler.rs
Normal file
510
crates/erp-plugin/src/handler/plugin_handler.rs
Normal 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)))
|
||||
}
|
||||
438
crates/erp-plugin/src/host.rs
Normal file
438
crates/erp-plugin/src/host.rs
Normal 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(¤t_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())
|
||||
}
|
||||
}
|
||||
26
crates/erp-plugin/src/lib.rs
Normal file
26
crates/erp-plugin/src/lib.rs
Normal 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;
|
||||
1809
crates/erp-plugin/src/manifest.rs
Normal file
1809
crates/erp-plugin/src/manifest.rs
Normal file
File diff suppressed because it is too large
Load Diff
200
crates/erp-plugin/src/module.rs
Normal file
200
crates/erp-plugin/src/module.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
109
crates/erp-plugin/src/notification.rs
Normal file
109
crates/erp-plugin/src/notification.rs
Normal 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(())
|
||||
}
|
||||
317
crates/erp-plugin/src/plugin_validator.rs
Normal file
317
crates/erp-plugin/src/plugin_validator.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
1136
crates/erp-plugin/src/service.rs
Normal file
1136
crates/erp-plugin/src/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
52
crates/erp-plugin/src/state.rs
Normal file
52
crates/erp-plugin/src/state.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
54
crates/erp-plugin/wit/plugin.wit
Normal file
54
crates/erp-plugin/wit/plugin.wit
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user