3.1 配置变更通知:
- update_config 增加 EventBus 参数
- 更新成功后发布 plugin.config.updated 事件
- handler 传入 event_bus
3.2 自定义视图:
- plugin_user_views 表迁移 (id/tenant_id/user_id/plugin_id/entity/view_name/view_config/is_default)
- CRUD API: GET/POST /plugins/{id}/{entity}/views, DELETE /plugins/{id}/{entity}/views/{view_id}
- 默认视图互斥逻辑
531 lines
16 KiB
Rust
531 lines
16 KiB
Rust
use axum::Extension;
|
||
use axum::extract::{FromRef, Multipart, Path, Query, State};
|
||
use axum::response::Json;
|
||
use uuid::Uuid;
|
||
|
||
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
|
||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||
|
||
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")?;
|
||
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)))
|
||
}
|