From b0ee3e495d66c5a4f8d8c59d552b0c158a64ffb2 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 17 Apr 2026 10:56:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20PATCH=20=E9=83=A8=E5=88=86?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=AB=AF=E7=82=B9=20=E2=80=94=20jsonb=5Fset?= =?UTF-8?q?=20=E5=AD=97=E6=AE=B5=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/erp-plugin/src/data_dto.rs | 9 ++- crates/erp-plugin/src/data_service.rs | 39 +++++++++++++ crates/erp-plugin/src/dynamic_table.rs | 57 +++++++++++++++++++ crates/erp-plugin/src/handler/data_handler.rs | 35 +++++++++++- crates/erp-plugin/src/module.rs | 1 + 5 files changed, 139 insertions(+), 2 deletions(-) diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs index 68caf9d..9d2f51d 100644 --- a/crates/erp-plugin/src/data_dto.rs +++ b/crates/erp-plugin/src/data_dto.rs @@ -17,13 +17,20 @@ pub struct CreatePluginDataReq { pub data: serde_json::Value, } -/// 更新插件数据请求 +/// 更新插件数据请求(全量替换) #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct UpdatePluginDataReq { pub data: serde_json::Value, pub version: i32, } +/// 部分更新请求(PATCH — 只合并提供的字段) +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PatchPluginDataReq { + pub data: serde_json::Value, + pub version: i32, +} + /// 插件数据列表查询参数 #[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] pub struct PluginDataListParams { diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs index 8dba347..3d2394c 100644 --- a/crates/erp-plugin/src/data_service.rs +++ b/crates/erp-plugin/src/data_service.rs @@ -252,6 +252,45 @@ impl PluginDataService { }) } + /// 部分更新(PATCH)— 只合并提供的字段 + pub async fn partial_update( + plugin_id: Uuid, + entity_name: &str, + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + partial_data: serde_json::Value, + expected_version: i32, + db: &sea_orm::DatabaseConnection, + ) -> AppResult { + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + + let (sql, values) = DynamicTableManager::build_patch_sql( + &info.table_name, id, tenant_id, operator_id, partial_data, expected_version, + ); + + #[derive(FromQueryResult)] + struct PatchResult { + id: Uuid, + data: serde_json::Value, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + version: i32, + } + + let result = PatchResult::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, sql, values, + )).one(db).await?.ok_or_else(|| AppError::VersionMismatch)?; + + Ok(PluginDataResp { + id: result.id.to_string(), + data: result.data, + created_at: Some(result.created_at), + updated_at: Some(result.updated_at), + version: Some(result.version), + }) + } + /// 删除(软删除) pub async fn delete( plugin_id: Uuid, diff --git a/crates/erp-plugin/src/dynamic_table.rs b/crates/erp-plugin/src/dynamic_table.rs index 7fd5010..8956a69 100644 --- a/crates/erp-plugin/src/dynamic_table.rs +++ b/crates/erp-plugin/src/dynamic_table.rs @@ -266,6 +266,44 @@ impl DynamicTableManager { (sql, values) } + /// 构建 PATCH SQL — 只更新 data 中提供的字段,未提供的保持不变 + /// 使用 jsonb_set 逐层合并,实现部分更新 + pub fn build_patch_sql( + table_name: &str, + id: Uuid, + tenant_id: Uuid, + user_id: Uuid, + partial_data: serde_json::Value, + version: i32, + ) -> (String, Vec) { + let mut set_expr = "data".to_string(); + if let Some(obj) = partial_data.as_object() { + for key in obj.keys() { + let clean_key = sanitize_identifier(key); + set_expr = format!( + "jsonb_set({}, '{{{}}}', $1::jsonb->'{}', true)", + set_expr, clean_key, clean_key + ); + } + } + + let sql = format!( + "UPDATE \"{}\" \ + SET data = {}, updated_at = NOW(), updated_by = $2, version = version + 1 \ + WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \ + RETURNING id, data, created_at, updated_at, version", + table_name, set_expr + ); + let values = vec![ + serde_json::to_string(&partial_data).unwrap_or_default().into(), + user_id.into(), + id.into(), + tenant_id.into(), + version.into(), + ]; + (sql, values) + } + /// 构建 DELETE SQL(软删除) pub fn build_delete_sql( table_name: &str, @@ -871,6 +909,25 @@ mod tests { ); } + // ===== build_patch_sql 测试 ===== + + #[test] + fn test_build_patch_sql_merges_fields() { + let (sql, values) = DynamicTableManager::build_patch_sql( + "plugin_test_customer", + Uuid::parse_str("00000000-0000-0000-0000-000000000099").unwrap(), + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + Uuid::parse_str("00000000-0000-0000-0000-000000000050").unwrap(), + serde_json::json!({"level": "vip", "status": "active"}), + 3, + ); + assert!(sql.contains("jsonb_set"), "PATCH 应使用 jsonb_set 合并"); + assert!(sql.contains("version = version + 1"), "PATCH 应更新版本号"); + assert!(sql.contains("WHERE id = $3"), "应有 id 条件"); + assert!(sql.contains("version = $5"), "应有乐观锁"); + assert_eq!(values.len(), 5, "应有 5 个参数"); + } + // ===== build_filtered_count_sql 测试 ===== #[test] diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs index 99a67d6..8828310 100644 --- a/crates/erp-plugin/src/handler/data_handler.rs +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -9,7 +9,7 @@ use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use crate::data_dto::{ AggregateItem, AggregateQueryParams, CountQueryParams, CreatePluginDataReq, - PluginDataListParams, PluginDataResp, UpdatePluginDataReq, + PatchPluginDataReq, PluginDataListParams, PluginDataResp, UpdatePluginDataReq, }; use crate::data_service::{PluginDataService, resolve_manifest_id}; use crate::state::PluginState; @@ -253,6 +253,39 @@ where Ok(Json(ApiResponse::ok(result))) } +#[utoipa::path( + patch, + path = "/api/v1/plugins/{plugin_id}/{entity}/{id}", + request_body = PatchPluginDataReq, + responses( + (status = 200, description = "部分更新成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// PATCH /api/v1/plugins/{plugin_id}/{entity}/{id} — 部分更新(jsonb_set 合并字段) +pub async fn patch_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>, + 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 fine_perm = compute_permission_code(&manifest_id, &entity, "update"); + require_permission(&ctx, &fine_perm)?; + + let result = PluginDataService::partial_update( + plugin_id, &entity, id, ctx.tenant_id, ctx.user_id, + req.data, req.version, &state.db, + ).await?; + + Ok(Json(ApiResponse::ok(result))) +} + #[utoipa::path( delete, path = "/api/v1/plugins/{plugin_id}/{entity}/{id}", diff --git a/crates/erp-plugin/src/module.rs b/crates/erp-plugin/src/module.rs index c0d9ebd..2e473b4 100644 --- a/crates/erp-plugin/src/module.rs +++ b/crates/erp-plugin/src/module.rs @@ -75,6 +75,7 @@ impl PluginModule { "/plugins/{plugin_id}/{entity}/{id}", get(crate::handler::data_handler::get_plugin_data::) .put(crate::handler::data_handler::update_plugin_data::) + .patch(crate::handler::data_handler::patch_plugin_data::) .delete(crate::handler::data_handler::delete_plugin_data::), ) // 数据统计路由