feat(plugin): P1-P4 审计修复 — 第三批 (配置变更通知 + 自定义视图)
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:
@@ -305,3 +305,26 @@ pub struct DanglingRef {
|
|||||||
/// 悬空的 UUID 值
|
/// 悬空的 UUID 值
|
||||||
pub dangling_value: String,
|
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>>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -412,6 +412,20 @@ impl PluginEngine {
|
|||||||
Ok(metrics.clone())
|
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 {
|
pub async fn is_running(&self, plugin_id: &str) -> bool {
|
||||||
if let Some(loaded) = self.plugins.get(plugin_id) {
|
if let Some(loaded) = self.plugins.get(plugin_id) {
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ use crate::data_dto::{
|
|||||||
CountQueryParams, CreatePluginDataReq, ExportParams, ImportReq, ImportResult,
|
CountQueryParams, CreatePluginDataReq, ExportParams, ImportReq, ImportResult,
|
||||||
PatchPluginDataReq, PluginDataListParams,
|
PatchPluginDataReq, PluginDataListParams,
|
||||||
PluginDataResp, PublicEntityResp, ReconciliationReport, ResolveLabelsReq, ResolveLabelsResp,
|
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::data_service::{DataScopeParams, PluginDataService, resolve_manifest_id};
|
||||||
use crate::state::PluginState;
|
use crate::state::PluginState;
|
||||||
|
|
||||||
@@ -935,3 +936,142 @@ where
|
|||||||
|
|
||||||
Ok(Json(ApiResponse::ok(report)))
|
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(())))
|
||||||
|
}
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ where
|
|||||||
req.config,
|
req.config,
|
||||||
req.version,
|
req.version,
|
||||||
&state.db,
|
&state.db,
|
||||||
|
Some(&state.event_bus),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::routing::{get, post, put};
|
use axum::routing::{delete, get, post, put};
|
||||||
use erp_core::module::ErpModule;
|
use erp_core::module::ErpModule;
|
||||||
|
|
||||||
pub struct PluginModule;
|
pub struct PluginModule;
|
||||||
@@ -131,6 +131,16 @@ impl PluginModule {
|
|||||||
.route(
|
.route(
|
||||||
"/plugins/{plugin_id}/reconcile",
|
"/plugins/{plugin_id}/reconcile",
|
||||||
post(crate::handler::data_handler::reconcile_refs::<S>),
|
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>),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 实体注册表路由
|
// 实体注册表路由
|
||||||
|
|||||||
@@ -446,6 +446,7 @@ impl PluginService {
|
|||||||
config: serde_json::Value,
|
config: serde_json::Value,
|
||||||
expected_version: i32,
|
expected_version: i32,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: Option<&erp_core::events::EventBus>,
|
||||||
) -> AppResult<PluginResp> {
|
) -> AppResult<PluginResp> {
|
||||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||||
|
|
||||||
@@ -462,12 +463,26 @@ impl PluginService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
let manifest_id = manifest.metadata.id.clone();
|
||||||
let mut active: plugin::ActiveModel = model.into();
|
let mut active: plugin::ActiveModel = model.into();
|
||||||
active.config_json = Set(config);
|
active.config_json = Set(config);
|
||||||
active.updated_at = Set(now);
|
active.updated_at = Set(now);
|
||||||
active.updated_by = Set(Some(operator_id));
|
active.updated_by = Set(Some(operator_id));
|
||||||
let model = active.update(db).await?;
|
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();
|
let entities = find_plugin_entities(plugin_id, tenant_id, db).await.unwrap_or_default();
|
||||||
Ok(plugin_model_to_resp(&model, &manifest, entities))
|
Ok(plugin_model_to_resp(&model, &manifest, entities))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ mod m20260419_000037_create_user_departments;
|
|||||||
mod m20260419_000038_fix_crm_permission_codes;
|
mod m20260419_000038_fix_crm_permission_codes;
|
||||||
mod m20260419_000039_entity_registry_columns;
|
mod m20260419_000039_entity_registry_columns;
|
||||||
mod m20260419_000040_plugin_market;
|
mod m20260419_000040_plugin_market;
|
||||||
|
mod m20260419_000041_plugin_user_views;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260419_000038_fix_crm_permission_codes::Migration),
|
Box::new(m20260419_000038_fix_crm_permission_codes::Migration),
|
||||||
Box::new(m20260419_000039_entity_registry_columns::Migration),
|
Box::new(m20260419_000039_entity_registry_columns::Migration),
|
||||||
Box::new(m20260419_000040_plugin_market::Migration),
|
Box::new(m20260419_000040_plugin_market::Migration),
|
||||||
|
Box::new(m20260419_000041_plugin_user_views::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user