diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs index 9d2f51d..def405a 100644 --- a/crates/erp-plugin/src/data_dto.rs +++ b/crates/erp-plugin/src/data_dto.rs @@ -72,3 +72,14 @@ pub struct CountQueryParams { /// JSON 格式过滤: {"field":"value"} pub filter: Option, } + +/// 批量操作请求 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct BatchActionReq { + /// 操作类型: "batch_delete" 或 "batch_update" + pub action: String, + /// 记录 ID 列表(上限 100) + pub ids: Vec, + /// batch_update 时的更新数据 + pub data: Option, +} diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs index 3d2394c..c8e4ae0 100644 --- a/crates/erp-plugin/src/data_service.rs +++ b/crates/erp-plugin/src/data_service.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use erp_core::error::{AppError, AppResult}; use erp_core::events::EventBus; -use crate::data_dto::PluginDataResp; +use crate::data_dto::{BatchActionReq, PluginDataResp}; use crate::dynamic_table::{sanitize_identifier, DynamicTableManager}; use crate::entity::plugin; use crate::entity::plugin_entity; @@ -372,6 +372,111 @@ impl PluginDataService { Ok(()) } + /// 批量操作 — batch_delete / batch_update + pub async fn batch( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + operator_id: Uuid, + req: BatchActionReq, + db: &sea_orm::DatabaseConnection, + ) -> AppResult { + if req.ids.is_empty() { + return Err(AppError::Validation("ids 不能为空".to_string())); + } + if req.ids.len() > 100 { + return Err(AppError::Validation("批量操作上限 100 条".to_string())); + } + + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + let ids: Vec = req + .ids + .iter() + .map(|s| Uuid::parse_str(s)) + .collect::, _>>() + .map_err(|_| AppError::Validation("ids 中包含无效的 UUID".to_string()))?; + + let affected = match req.action.as_str() { + "batch_delete" => { + let placeholders: Vec = ids + .iter() + .enumerate() + .map(|(i, _)| format!("${}", i + 2)) + .collect(); + let sql = format!( + "UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() \ + WHERE tenant_id = $1 AND id IN ({}) AND deleted_at IS NULL", + info.table_name, + placeholders.join(", ") + ); + let mut values = vec![tenant_id.into()]; + for id in &ids { + values.push((*id).into()); + } + let result = db + .execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .await?; + result.rows_affected() + } + "batch_update" => { + let update_data = req.data.ok_or_else(|| { + AppError::Validation("batch_update 需要 data 字段".to_string()) + })?; + let mut set_expr = "data".to_string(); + if let Some(obj) = update_data.as_object() { + for key in obj.keys() { + let clean_key = sanitize_identifier(key); + set_expr = format!( + "jsonb_set({}, '{{{}}}', $2::jsonb->'{}', true)", + set_expr, clean_key, clean_key + ); + } + } + let placeholders: Vec = ids + .iter() + .enumerate() + .map(|(i, _)| format!("${}", i + 3)) + .collect(); + let sql = format!( + "UPDATE \"{}\" SET data = {}, updated_at = NOW(), updated_by = $1, version = version + 1 \ + WHERE tenant_id = $1 AND id IN ({}) AND deleted_at IS NULL", + info.table_name, + set_expr, + placeholders.join(", ") + ); + let mut values = vec![operator_id.into()]; + values.push( + serde_json::to_string(&update_data) + .unwrap_or_default() + .into(), + ); + for id in &ids { + values.push((*id).into()); + } + let result = db + .execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .await?; + result.rows_affected() + } + _ => { + return Err(AppError::Validation(format!( + "不支持的批量操作: {}", + req.action + ))) + } + }; + + Ok(affected) + } + /// 统计记录数(支持过滤和搜索) pub async fn count( plugin_id: Uuid, diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs index 8828310..6217e83 100644 --- a/crates/erp-plugin/src/handler/data_handler.rs +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -8,7 +8,7 @@ use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use crate::data_dto::{ - AggregateItem, AggregateQueryParams, CountQueryParams, CreatePluginDataReq, + AggregateItem, AggregateQueryParams, BatchActionReq, CountQueryParams, CreatePluginDataReq, PatchPluginDataReq, PluginDataListParams, PluginDataResp, UpdatePluginDataReq, }; use crate::data_service::{PluginDataService, resolve_manifest_id}; @@ -322,6 +322,49 @@ where Ok(Json(ApiResponse::ok(()))) } +#[utoipa::path( + post, + path = "/api/v1/plugins/{plugin_id}/{entity}/batch", + request_body = BatchActionReq, + responses( + (status = 200, description = "批量操作成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// POST /api/v1/plugins/{plugin_id}/{entity}/batch — 批量操作 (batch_delete / batch_update) +pub async fn batch_plugin_data( + 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 action_perm = match req.action.as_str() { + "batch_delete" => "delete", + "batch_update" => "update", + _ => "update", + }; + let fine_perm = compute_permission_code(&manifest_id, &entity, action_perm); + require_permission(&ctx, &fine_perm)?; + + let affected = PluginDataService::batch( + plugin_id, + &entity, + ctx.tenant_id, + ctx.user_id, + req, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(affected))) +} + #[utoipa::path( get, path = "/api/v1/plugins/{plugin_id}/{entity}/count", diff --git a/crates/erp-plugin/src/module.rs b/crates/erp-plugin/src/module.rs index 2e473b4..3f8aff0 100644 --- a/crates/erp-plugin/src/module.rs +++ b/crates/erp-plugin/src/module.rs @@ -86,6 +86,11 @@ impl PluginModule { .route( "/plugins/{plugin_id}/{entity}/aggregate", get(crate::handler::data_handler::aggregate_plugin_data::), + ) + // 批量操作路由 + .route( + "/plugins/{plugin_id}/{entity}/batch", + post(crate::handler::data_handler::batch_plugin_data::), ); admin_routes.merge(data_routes)