feat(plugin): 集成 WASM 插件系统到主服务并修复链路问题
- 新增 erp-plugin crate:插件管理、WASM 运行时、动态表、数据 CRUD - 新增前端插件管理页面(PluginAdmin/PluginCRUDPage)和 API 层 - 新增插件数据迁移(plugins/plugin_entities/plugin_event_subscriptions) - 新增权限补充迁移(为已有租户补充 plugin.admin/plugin.list 权限) - 修复 PluginAdmin 页面 InstallOutlined 图标不存在的崩溃问题 - 修复 settings 唯一索引迁移顺序错误(先去重再建索引) - 更新 wiki 和 CLAUDE.md 反映插件系统集成状态 - 新增 dev.ps1 一键启动脚本
This commit is contained in:
379
crates/erp-plugin/src/handler/plugin_handler.rs
Normal file
379
crates/erp-plugin/src/handler/plugin_handler.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
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 text = field.text().await.map_err(|e| {
|
||||
AppError::Validation(format!("读取 Manifest 失败: {}", 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?;
|
||||
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(
|
||||
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,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
Reference in New Issue
Block a user