feat(plugin): P1-P4 审计修复 — 第三批 (配置变更通知 + 自定义视图)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

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}
- 默认视图互斥逻辑
This commit is contained in:
iven
2026-04-19 18:25:03 +08:00
parent 0a041c3d22
commit a7a48167ca
8 changed files with 253 additions and 2 deletions

View File

@@ -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<bool>,
}
/// 用户视图响应
#[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<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}

View File

@@ -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) {

View File

@@ -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<Vec<UserViewResp>>)
),
tag = "Plugin Views",
)]
pub async fn list_user_views<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity)): Path<(Uuid, String)>,
) -> Result<Json<ApiResponse<Vec<crate::data_dto::UserViewResp>>>, AppError>
where
PluginState: FromRef<S>,
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<chrono::DateTime<chrono::Utc>>,
updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
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<UserViewResp>)
),
tag = "Plugin Views",
)]
pub async fn create_user_view<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity)): Path<(Uuid, String)>,
Json(req): Json<crate::data_dto::UserViewReq>,
) -> Result<Json<ApiResponse<crate::data_dto::UserViewResp>>, AppError>
where
PluginState: FromRef<S>,
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<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity, view_id)): Path<(Uuid, String, Uuid)>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
PluginState: FromRef<S>,
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(())))
}

View File

@@ -422,6 +422,7 @@ where
req.config,
req.version,
&state.db,
Some(&state.event_bus),
)
.await?;
Ok(Json(ApiResponse::ok(result)))

View File

@@ -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::<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>),
);
// 实体注册表路由

View File

@@ -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<PluginResp> {
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))
}

View File

@@ -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),
]
}
}

View File

@@ -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
}
}