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 值
|
||||
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())
|
||||
}
|
||||
|
||||
/// 刷新插件内存配置(配置变更后调用)
|
||||
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) {
|
||||
|
||||
@@ -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(())))
|
||||
}
|
||||
|
||||
@@ -422,6 +422,7 @@ where
|
||||
req.config,
|
||||
req.version,
|
||||
&state.db,
|
||||
Some(&state.event_bus),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -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>),
|
||||
);
|
||||
|
||||
// 实体注册表路由
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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