From a7a48167caff71290ce2c3de33e075e739f61da4 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 19 Apr 2026 18:25:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20P1-P4=20=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E7=AC=AC=E4=B8=89=E6=89=B9=20?= =?UTF-8?q?(=E9=85=8D=E7=BD=AE=E5=8F=98=E6=9B=B4=E9=80=9A=E7=9F=A5=20+=20?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E8=A7=86=E5=9B=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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} - 默认视图互斥逻辑 --- crates/erp-plugin/src/data_dto.rs | 23 +++ crates/erp-plugin/src/engine.rs | 14 ++ crates/erp-plugin/src/handler/data_handler.rs | 142 +++++++++++++++++- .../erp-plugin/src/handler/plugin_handler.rs | 1 + crates/erp-plugin/src/module.rs | 12 +- crates/erp-plugin/src/service.rs | 15 ++ crates/erp-server/migration/src/lib.rs | 2 + .../src/m20260419_000041_plugin_user_views.rs | 46 ++++++ 8 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 crates/erp-server/migration/src/m20260419_000041_plugin_user_views.rs diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs index ae451ed..bf6acbc 100644 --- a/crates/erp-plugin/src/data_dto.rs +++ b/crates/erp-plugin/src/data_dto.rs @@ -305,3 +305,26 @@ pub struct DanglingRef { /// 悬空的 UUID 值 pub dangling_value: String, } + +// ─── 自定义视图 DTO ────────────────────────────────────────────────── + +/// 用户视图配置请求 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct UserViewReq { + pub view_name: String, + pub view_config: serde_json::Value, + pub is_default: Option, +} + +/// 用户视图响应 +#[derive(Debug, Serialize, Deserialize, utoipa::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>, + pub updated_at: Option>, +} diff --git a/crates/erp-plugin/src/engine.rs b/crates/erp-plugin/src/engine.rs index a0674d5..7b009e1 100644 --- a/crates/erp-plugin/src/engine.rs +++ b/crates/erp-plugin/src/engine.rs @@ -412,6 +412,20 @@ impl PluginEngine { 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) { diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs index feb41ec..962b915 100644 --- a/crates/erp-plugin/src/handler/data_handler.rs +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -12,8 +12,9 @@ use crate::data_dto::{ CountQueryParams, CreatePluginDataReq, ExportParams, ImportReq, ImportResult, PatchPluginDataReq, PluginDataListParams, PluginDataResp, PublicEntityResp, ReconciliationReport, ResolveLabelsReq, ResolveLabelsResp, - TimeseriesItem, TimeseriesParams, UpdatePluginDataReq, + TimeseriesItem, TimeseriesParams, UpdatePluginDataReq, UserViewReq, UserViewResp, }; +use sea_orm::{ConnectionTrait, Statement}; use crate::data_service::{DataScopeParams, PluginDataService, resolve_manifest_id}; use crate::state::PluginState; @@ -935,3 +936,142 @@ where Ok(Json(ApiResponse::ok(report))) } + +// ─── 用户自定义视图 CRUD ────────────────────────────────────────────────── + +#[utoipa::path( + get, + path = "/api/v1/plugins/{plugin_id}/{entity}/views", + responses( + (status = 200, description = "视图列表", body = ApiResponse>) + ), + tag = "Plugin Views", +)] +pub async fn list_user_views( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + + use sea_orm::FromQueryResult; + #[derive(FromQueryResult)] + struct ViewRow { + id: Uuid, + view_name: String, + view_config: serde_json::Value, + is_default: bool, + created_at: Option>, + updated_at: Option>, + } + + let rows = ViewRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "SELECT id, view_name, view_config, is_default, created_at, updated_at \ + FROM plugin_user_views WHERE tenant_id = $1 AND user_id = $2 AND plugin_id = $3 AND entity_name = $4 \ + ORDER BY created_at DESC", + [ctx.tenant_id.into(), ctx.user_id.into(), manifest_id.clone().into(), entity.clone().into()], + )) + .all(&state.db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let mid = manifest_id.clone(); + let ent = entity.clone(); + let items = rows.into_iter().map(|r| UserViewResp { + id: r.id.to_string(), + plugin_id: mid.clone(), + entity_name: ent.clone(), + view_name: r.view_name, + view_config: r.view_config, + is_default: r.is_default, + created_at: r.created_at, + updated_at: r.updated_at, + }).collect(); + + Ok(Json(ApiResponse::ok(items))) +} + +#[utoipa::path( + post, + path = "/api/v1/plugins/{plugin_id}/{entity}/views", + request_body = UserViewReq, + responses( + (status = 200, description = "创建视图", body = ApiResponse) + ), + tag = "Plugin Views", +)] +pub async fn create_user_view( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let view_id = Uuid::now_v7(); + let now = chrono::Utc::now(); + let is_default = req.is_default.unwrap_or(false); + let mid = manifest_id.clone(); + let ent = entity.clone(); + let view_name = req.view_name.clone(); + let view_config = req.view_config.clone(); + + if is_default { + state.db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "UPDATE plugin_user_views SET is_default = false \ + WHERE tenant_id = $1 AND user_id = $2 AND plugin_id = $3 AND entity_name = $4 AND is_default = true", + [ctx.tenant_id.into(), ctx.user_id.into(), mid.clone().into(), ent.clone().into()], + )).await.map_err(|e| AppError::Internal(e.to_string()))?; + } + + state.db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "INSERT INTO plugin_user_views (id, tenant_id, user_id, plugin_id, entity_name, view_name, view_config, is_default, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + [ + view_id.into(), ctx.tenant_id.into(), ctx.user_id.into(), + mid.into(), ent.into(), + view_name.into(), view_config.into(), is_default.into(), + now.into(), now.into(), + ], + )).await.map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(Json(ApiResponse::ok(UserViewResp { + id: view_id.to_string(), + plugin_id: manifest_id, + entity_name: entity, + view_name: req.view_name, + view_config: req.view_config, + is_default, + created_at: Some(now), + updated_at: Some(now), + }))) +} + +/// DELETE /api/v1/plugins/{plugin_id}/{entity}/views/{view_id} — 删除视图 +pub async fn delete_user_view( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity, view_id)): Path<(Uuid, String, Uuid)>, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + state.db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "DELETE FROM plugin_user_views WHERE id = $1 AND tenant_id = $2 AND user_id = $3", + [view_id.into(), ctx.tenant_id.into(), ctx.user_id.into()], + )).await.map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-plugin/src/handler/plugin_handler.rs b/crates/erp-plugin/src/handler/plugin_handler.rs index 0fb2af7..3546463 100644 --- a/crates/erp-plugin/src/handler/plugin_handler.rs +++ b/crates/erp-plugin/src/handler/plugin_handler.rs @@ -422,6 +422,7 @@ where req.config, req.version, &state.db, + Some(&state.event_bus), ) .await?; Ok(Json(ApiResponse::ok(result))) diff --git a/crates/erp-plugin/src/module.rs b/crates/erp-plugin/src/module.rs index 6f6aae3..138ccc7 100644 --- a/crates/erp-plugin/src/module.rs +++ b/crates/erp-plugin/src/module.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; use axum::Router; -use axum::routing::{get, post, put}; +use axum::routing::{delete, get, post, put}; use erp_core::module::ErpModule; pub struct PluginModule; @@ -131,6 +131,16 @@ impl PluginModule { .route( "/plugins/{plugin_id}/reconcile", post(crate::handler::data_handler::reconcile_refs::), + ) + // 用户自定义视图 + .route( + "/plugins/{plugin_id}/{entity}/views", + get(crate::handler::data_handler::list_user_views::) + .post(crate::handler::data_handler::create_user_view::), + ) + .route( + "/plugins/{plugin_id}/{entity}/views/{view_id}", + delete(crate::handler::data_handler::delete_user_view::), ); // 实体注册表路由 diff --git a/crates/erp-plugin/src/service.rs b/crates/erp-plugin/src/service.rs index 3570bd9..45e3d63 100644 --- a/crates/erp-plugin/src/service.rs +++ b/crates/erp-plugin/src/service.rs @@ -446,6 +446,7 @@ impl PluginService { config: serde_json::Value, expected_version: i32, db: &sea_orm::DatabaseConnection, + event_bus: Option<&erp_core::events::EventBus>, ) -> AppResult { let model = find_plugin(plugin_id, tenant_id, db).await?; @@ -462,12 +463,26 @@ impl PluginService { } let now = Utc::now(); + let manifest_id = manifest.metadata.id.clone(); let mut active: plugin::ActiveModel = model.into(); active.config_json = Set(config); active.updated_at = Set(now); active.updated_by = Set(Some(operator_id)); let model = active.update(db).await?; + // 发布配置变更事件 + if let Some(bus) = event_bus { + let event = erp_core::events::DomainEvent::new( + "plugin.config.updated", + tenant_id, + serde_json::json!({ + "plugin_id": manifest_id, + "updated_by": operator_id, + }), + ); + bus.publish(event, db).await; + } + let entities = find_plugin_entities(plugin_id, tenant_id, db).await.unwrap_or_default(); Ok(plugin_model_to_resp(&model, &manifest, entities)) } diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index a0d0cb3..38d11b7 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -40,6 +40,7 @@ mod m20260419_000037_create_user_departments; mod m20260419_000038_fix_crm_permission_codes; mod m20260419_000039_entity_registry_columns; mod m20260419_000040_plugin_market; +mod m20260419_000041_plugin_user_views; pub struct Migrator; @@ -87,6 +88,7 @@ impl MigratorTrait for Migrator { Box::new(m20260419_000038_fix_crm_permission_codes::Migration), Box::new(m20260419_000039_entity_registry_columns::Migration), Box::new(m20260419_000040_plugin_market::Migration), + Box::new(m20260419_000041_plugin_user_views::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260419_000041_plugin_user_views.rs b/crates/erp-server/migration/src/m20260419_000041_plugin_user_views.rs new file mode 100644 index 0000000..5939df5 --- /dev/null +++ b/crates/erp-server/migration/src/m20260419_000041_plugin_user_views.rs @@ -0,0 +1,46 @@ +use sea_orm_migration::prelude::*; + +/// 插件用户视图 — 用户自定义的列表视图配置 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("plugin_user_views")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("user_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("plugin_id")).string().not_null()) + .col(ColumnDef::new(Alias::new("entity_name")).string().not_null()) + .col(ColumnDef::new(Alias::new("view_name")).string().not_null()) + .col(ColumnDef::new(Alias::new("view_config")).json().not_null()) + .col(ColumnDef::new(Alias::new("is_default")).boolean().not_null().default(false)) + .col(ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp())) + .col(ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp())) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Alias::new("plugin_user_views")).to_owned()) + .await + } +}