From a7cf44cd462f72c821bc5b122ee2d5a36cae3060 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 17 Apr 2026 09:57:58 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20CRM=20=E6=8F=92=E4=BB=B6=E5=9F=BA?= =?UTF-8?q?=E5=BA=A7=E5=8D=87=E7=BA=A7=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= =?UTF-8?q?=20=E2=80=94=204=20Chunk=2036=20Task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 1: JSONB 存储优化 (Generated Column + pg_trgm + Keyset + Schema 缓存) Chunk 2: 数据完整性框架 (ref_entity + 级联删除 + 字段校验 + 循环检测) Chunk 3: 行级数据权限 (data_scope + TenantContext 扩展 + fallback 收紧) Chunk 4: 前端页面能力增强 (entity_select + kanban + 批量操作 + 图表) --- ...2026-04-17-crm-plugin-base-upgrade-plan.md | 3796 +++++++++++++++++ 1 file changed, 3796 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-17-crm-plugin-base-upgrade-plan.md diff --git a/docs/superpowers/plans/2026-04-17-crm-plugin-base-upgrade-plan.md b/docs/superpowers/plans/2026-04-17-crm-plugin-base-upgrade-plan.md new file mode 100644 index 0000000..5b80f89 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-crm-plugin-base-upgrade-plan.md @@ -0,0 +1,3796 @@ +# CRM 插件基座升级实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 升级 CRM 插件基座 — JSONB 存储优化、数据完整性框架、行级数据权限、前端页面能力增强 + +**Architecture:** 基座优先策略,所有改进沉淀为插件平台通用能力。后端 Rust (Axum + SeaORM + PostgreSQL),前端 React + Ant Design。依赖顺序:Generated Column → ref_entity → data_scope → 前端增强。 + +**Tech Stack:** Rust/Axum, PostgreSQL 16 (Generated Column + pg_trgm), Redis 7 (缓存), React 19 + Ant Design 6 + @ant-design/charts, @dnd-kit + +**设计规格:** `docs/superpowers/specs/2026-04-17-crm-plugin-base-upgrade-design.md` + +--- + +## 依赖关系图 + +``` +Chunk 1 (JSONB 存储) ──┬──→ Chunk 2 (数据完整性) + ├──→ Chunk 3 (行级权限) + └──→ Chunk 4 (前端增强) + │ │ │ + │ pg_trgm 迁移 │ manifest 扩展 │ TenantContext 扩展 + │ Generated Column │ ref_entity/relations │ data_scope + │ Keyset Pagination │ validation/no_cycle │ permission fallback + │ Schema 缓存 │ cascade delete │ + │ 聚合 Redis 缓存 │ │ +``` + +Chunk 1 是其他所有 Chunk 的基础。Chunk 2 和 Chunk 3 互相独立可并行。Chunk 4 依赖 Chunk 1-3 的后端 API。 + +--- + +## Chunk 1: JSONB 存储优化 + +### Task 1.1: 数据库迁移 — pg_trgm 扩展 + plugin_entity_columns 元数据表 + +**Files:** +- Create: `crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs` +- Modify: `crates/erp-server/migration/src/lib.rs` + +- [ ] **Step 1: 创建迁移文件** + +```rust +// crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs +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> { + // 启用 pg_trgm 扩展(加速 ILIKE '%keyword%' 搜索) + manager + .get_connection() + .execute_unprepared("CREATE EXTENSION IF NOT EXISTS pg_trgm") + .await?; + + // 插件实体列元数据表 — 记录哪些字段被提取为 Generated Column + manager + .create_table( + Table::create() + .table(Alias::new("plugin_entity_columns")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .default(Expr::cust("gen_random_uuid()")) + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("plugin_entity_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("field_name")).string().not_null()) + .col(ColumnDef::new(Alias::new("column_name")).string().not_null()) + .col(ColumnDef::new(Alias::new("sql_type")).string().not_null()) + .col( + ColumnDef::new(Alias::new("is_generated")) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::cust("NOW()")), + ) + .to_owned(), + ) + .await?; + + // plugin_entity_id 外键 + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_plugin_entity_columns_entity") + .from( + Alias::new("plugin_entity_columns"), + Alias::new("plugin_entity_id"), + ) + .to(Alias::new("plugin_entities"), Alias::new("id")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(Alias::new("plugin_entity_columns")) + .to_owned(), + ) + .await?; + // pg_trgm 不卸载(其他功能可能依赖) + Ok(()) + } +} +``` + +- [ ] **Step 2: 注册迁移** + +在 `crates/erp-server/migration/src/lib.rs` 中添加: + +```rust +mod m20260418_000035_pg_trgm_and_entity_columns; +``` + +在 `migrations()` vec 末尾添加: + +```rust +Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration), +``` + +- [ ] **Step 3: 验证迁移** + +Run: `cargo check -p erp-server` +Expected: 编译通过 + +Run: `cargo test -p erp-server -- --test-threads=1`(或启动服务让迁移自动执行) +Expected: 迁移成功 + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs crates/erp-server/migration/src/lib.rs +git commit -m "feat(db): 添加 pg_trgm 扩展 + plugin_entity_columns 元数据表" +``` + +--- + +### Task 1.2: manifest.rs — Generated Column 类型映射 + +**Files:** +- Modify: `crates/erp-plugin/src/manifest.rs:49-84` (PluginField, PluginFieldType) + +- [ ] **Step 1: 写失败测试** + +在 `crates/erp-plugin/src/manifest.rs` 的 `#[cfg(test)] mod tests` 中添加: + +```rust +#[test] +fn field_type_to_sql_mapping() { + use crate::manifest::PluginFieldType; + assert_eq!(PluginFieldType::String.generated_sql_type(), "TEXT"); + assert_eq!(PluginFieldType::Integer.generated_sql_type(), "INTEGER"); + assert_eq!(PluginFieldType::Float.generated_sql_type(), "DOUBLE PRECISION"); + assert_eq!(PluginFieldType::Decimal.generated_sql_type(), "NUMERIC"); + assert_eq!(PluginFieldType::Boolean.generated_sql_type(), "BOOLEAN"); + assert_eq!(PluginFieldType::Date.generated_sql_type(), "DATE"); + assert_eq!(PluginFieldType::DateTime.generated_sql_type(), "TIMESTAMPTZ"); + assert_eq!(PluginFieldType::Uuid.generated_sql_type(), "UUID"); + assert_eq!(PluginFieldType::Json.generated_sql_type(), "TEXT"); // JSON 不适合 Generated Column +} + +#[test] +fn field_type_generated_expression() { + use crate::manifest::PluginFieldType; + assert_eq!(PluginFieldType::String.generated_expr("name"), "data->>'name'"); + assert_eq!(PluginFieldType::Integer.generated_expr("age"), "(data->>'age')::INTEGER"); + assert_eq!(PluginFieldType::Uuid.generated_expr("ref_id"), "(data->>'ref_id')::UUID"); +} +``` + +- [ ] **Step 2: 验证测试失败** + +Run: `cargo test -p erp-plugin -- generated_sql_type` +Expected: 编译失败(方法不存在) + +- [ ] **Step 3: 实现 PluginFieldType 扩展** + +在 `PluginFieldType` enum 定义之后添加 impl 块: + +```rust +impl PluginFieldType { + /// Generated Column 的 SQL 类型 + pub fn generated_sql_type(&self) -> &'static str { + match self { + Self::String | Self::Json => "TEXT", + Self::Integer => "INTEGER", + Self::Float => "DOUBLE PRECISION", + Self::Decimal => "NUMERIC", + Self::Boolean => "BOOLEAN", + Self::Date => "DATE", + Self::DateTime => "TIMESTAMPTZ", + Self::Uuid => "UUID", + } + } + + /// Generated Column 的表达式 — TEXT 类型直接取值,其他类型做类型转换 + pub fn generated_expr(&self, field_name: &str) -> String { + match self { + Self::String | Self::Json => format!("data->>'{}'", field_name), + _ => format!("(data->>'{}')::{}", field_name, self.generated_sql_type()), + } + } + + /// 该类型是否适合生成 Generated Column(JSON 类型不适合) + pub fn supports_generated_column(&self) -> bool { + !matches!(self, Self::Json) + } +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: `cargo test -p erp-plugin -- generated` +Expected: 全部 PASS + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-plugin/src/manifest.rs +git commit -m "feat(plugin): PluginFieldType 添加 Generated Column 类型映射" +``` + +--- + +### Task 1.3: dynamic_table.rs — Generated Column DDL + 索引策略重写 + +**Files:** +- Modify: `crates/erp-plugin/src/dynamic_table.rs:27-118` (create_table 方法) + +- [ ] **Step 1: 写失败测试** + +在 `dynamic_table.rs` 的 `#[cfg(test)] mod tests` 中添加: + +```rust +#[test] +fn test_build_create_table_sql_with_generated_columns() { + use crate::manifest::{PluginEntity, PluginField, PluginFieldType}; + + let entity = PluginEntity { + name: "customer".to_string(), + display_name: "客户".to_string(), + fields: vec![ + PluginField { + name: "code".to_string(), + field_type: PluginFieldType::String, + required: true, unique: true, + display_name: Some("编码".to_string()), + searchable: Some(true), + ..Default::default_for_field() + }, + PluginField { + name: "level".to_string(), + field_type: PluginFieldType::String, + filterable: true, + display_name: Some("等级".to_string()), + ..Default::default_for_field() + }, + PluginField { + name: "sort_order".to_string(), + field_type: PluginFieldType::Integer, + sortable: true, + display_name: Some("排序".to_string()), + ..Default::default_for_field() + }, + ], + indexes: vec![], + }; + + let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity); + + // 验证 Generated Column 存在 + assert!(sql.contains("_f_code"), "应包含 _f_code Generated Column"); + assert!(sql.contains("_f_level"), "应包含 _f_level Generated Column"); + assert!(sql.contains("_f_sort_order"), "应包含 _f_sort_order Generated Column"); + assert!(sql.contains("GENERATED ALWAYS AS"), "应包含 GENERATED ALWAYS AS"); + // Integer 类型需要类型转换 + assert!(sql.contains("::INTEGER"), "Integer 字段应有类型转换"); +} + +#[test] +fn test_build_create_table_sql_pg_trgm_search_index() { + use crate::manifest::{PluginEntity, PluginField, PluginFieldType}; + + let entity = PluginEntity { + name: "customer".to_string(), + display_name: "客户".to_string(), + fields: vec![ + PluginField { + name: "name".to_string(), + field_type: PluginFieldType::String, + searchable: Some(true), + display_name: Some("名称".to_string()), + ..Default::default_for_field() + }, + ], + indexes: vec![], + }; + + let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity); + assert!(sql.contains("gin_trgm_ops"), "searchable 字段应使用 pg_trgm GIN 索引"); +} +``` + +> **注意:** `PluginField` 当前没有 `Default` 实现。为每个新增字段维护 `default_for_field()` 容易遗漏。 +> **推荐做法:** 直接为 `PluginField` 派生 `#[derive(Default)]`,并让 `PluginFieldType` 的 Default 为 `String`: +> +> ```rust +> #[derive(Debug, Clone, Serialize, Deserialize, Default)] +> #[serde(rename_all = "snake_case")] +> pub enum PluginFieldType { +> #[default] +> String, +> Integer, Float, Decimal, Boolean, Date, DateTime, Json, Uuid, +> } +> ``` +> +> 这样 `PluginField::default()` 自动包含所有字段的默认值,新增字段无需手动同步。如果派生 Default 不可行(因为 serde 需求),则使用以下手动辅助函数: +> +> ⚠️ **重要:** 每个扩展 `PluginField` 的 Task 都必须同步更新 `default_for_field()`,否则后续 Task 的测试会编译失败。 + +在 `PluginField` struct 的 `impl PluginField` 块中添加: + +```rust +impl PluginField { + /// 用于测试的默认值构造器 + /// ⚠️ 每次新增 PluginField 字段后必须同步更新此方法 + #[cfg(test)] + pub fn default_for_field() -> Self { + Self { + name: String::new(), + field_type: PluginFieldType::String, + required: false, + unique: false, + default: None, + display_name: None, + ui_widget: None, + options: None, + searchable: None, + filterable: None, + sortable: None, + visible_when: None, + ref_entity: None, // Task 2.1 新增 + validation: None, // Task 2.1 新增 + no_cycle: None, // Task 2.1 新增 + scope_role: None, // Task 3.4 新增 + } + } +} +``` + +- [ ] **Step 2: 验证测试失败** + +Run: `cargo test -p erp-plugin -- test_build_create_table` +Expected: 编译失败(`build_create_table_sql` 方法不存在,`default_for_field` 方法不存在) + +- [ ] **Step 3: 重写 create_table 为 build_create_table_sql + 新的 create_table** + +在 `DynamicTableManager` 中新增纯函数方法(不执行 SQL,只生成 DDL 字符串): + +```rust +/// 生成包含 Generated Column 的建表 DDL +pub fn build_create_table_sql(plugin_id: &str, entity: &PluginEntity) -> String { + let table_name = Self::table_name(plugin_id, &entity.name); + let t = sanitize_identifier(&table_name); + + let mut gen_cols = Vec::new(); + let mut indexes = Vec::new(); + + for field in &entity.fields { + if !field.field_type.supports_generated_column() { + continue; + } + // 提取规则:unique / (required && (sortable||filterable)) / sortable / filterable + let should_extract = field.unique + || field.sortable == Some(true) + || field.filterable == Some(true) + || (field.required && (field.sortable == Some(true) || field.filterable == Some(true))); + + if !should_extract { + continue; + } + + let col_name = format!("_f_{}", sanitize_identifier(&field.name)); + let sql_type = field.field_type.generated_sql_type(); + let expr = field.field_type.generated_expr(&sanitize_identifier(&field.name)); + + gen_cols.push(format!( + " \"{}\" {} GENERATED ALWAYS AS ({}) STORED", + col_name, sql_type, expr + )); + + // 索引策略 + let col_idx = format!("{}_{}", sanitize_identifier(&table_name), col_name); + if field.unique { + indexes.push(format!( + "CREATE UNIQUE INDEX IF NOT EXISTS \"idx_{}_uniq\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL", + col_idx, table_name, col_name + )); + } else { + indexes.push(format!( + "CREATE INDEX IF NOT EXISTS \"idx_{}\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL", + col_idx, table_name, col_name + )); + } + } + + // pg_trgm 索引(searchable 字段) + for field in &entity.fields { + if field.searchable == Some(true) && matches!(field.field_type, PluginFieldType::String) { + let sf = sanitize_identifier(&field.name); + indexes.push(format!( + "CREATE INDEX IF NOT EXISTS \"idx_{}_{}_trgm\" ON \"{}\" USING GIN ((data->>'{}') gin_trgm_ops) WHERE deleted_at IS NULL", + sanitize_identifier(&table_name), sf, table_name, sf + )); + } + } + + // 覆盖索引(tenant_id + created_at,加速默认排序查询) + indexes.push(format!( + "CREATE INDEX IF NOT EXISTS \"idx_{}_tenant_cover\" ON \"{}\" (tenant_id, created_at DESC) INCLUDE (id, data, updated_at, version) WHERE deleted_at IS NULL", + sanitize_identifier(&table_name), table_name + )); + + let gen_cols_sql = if gen_cols.is_empty() { + String::new() + } else { + format!(",\n{}", gen_cols.join(",\n")) + }; + + format!( + "CREATE TABLE IF NOT EXISTS \"{}\" (\ + \"id\" UUID PRIMARY KEY DEFAULT gen_random_uuid(), \ + \"tenant_id\" UUID NOT NULL, \ + \"data\" JSONB NOT NULL DEFAULT '{{}}'{gen_cols}, \ + \"created_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \ + \"updated_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \ + \"created_by\" UUID, \ + \"updated_by\" UUID, \ + \"deleted_at\" TIMESTAMPTZ, \ + \"version\" INT NOT NULL DEFAULT 1);\n\ + {}", + table_name, + indexes.join(";\n"), + gen_cols = gen_cols_sql, + ) +} +``` + +然后重构现有 `create_table` 方法调用新的 DDL 生成器: + +```rust +/// 创建动态表(执行 DDL) +pub async fn create_table( + db: &DatabaseConnection, + plugin_id: &str, + entity: &PluginEntity, +) -> PluginResult<()> { + let ddl = Self::build_create_table_sql(plugin_id, entity); + + // 分号分割,逐条执行 + for sql in ddl.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) { + tracing::info!(sql = %sql, "Executing DDL"); + db.execute_unprepared(sql).await.map_err(|e| { + tracing::error!(sql = %sql, error = %e, "DDL execution failed"); + PluginError::DatabaseError(e.to_string()) + })?; + } + + tracing::info!(plugin_id = %plugin_id, entity = %entity.name, "Dynamic table created with Generated Columns"); + Ok(()) +} +``` + +> **重要:** 删除原有的手动索引创建循环(原 `create_table` 中的 for field in &entity.fields 索引创建代码),全部由 `build_create_table_sql` 生成。 + +- [ ] **Step 4: 运行测试** + +Run: `cargo test -p erp-plugin -- test_build_create_table` +Expected: PASS + +Run: `cargo test -p erp-plugin` (全部测试) +Expected: 原有 15 个测试 + 新增 2 个测试全部 PASS + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-plugin/src/dynamic_table.rs +git commit -m "feat(plugin): create_table 使用 Generated Column + pg_trgm + 覆盖索引" +``` + +--- + +### Task 1.4: dynamic_table.rs — SQL 查询路由(Generated Column 优先) + +**Files:** +- Modify: `crates/erp-plugin/src/dynamic_table.rs` + +当 Generated Column 存在时,查询中 `data->>'field'` 应替换为 `_f_field`,利用 B-tree 索引。 + +- [ ] **Step 1: 写失败测试** + +```rust +#[test] +fn test_field_reference_uses_generated_column() { + // 当字段存在 Generated Column 时,SQL 应使用 _f_{name} 而非 data->>'{name}' + let generated_fields = vec!["code".to_string(), "status".to_string(), "level".to_string()]; + let ref_fn = DynamicTableManager::field_reference_fn(&generated_fields); + + assert_eq!(ref_fn("code"), "_f_code"); + assert_eq!(ref_fn("status"), "_f_status"); + assert_eq!(ref_fn("name"), "data->>'name'"); // 非 Generated Column + assert_eq!(ref_fn("remark"), "data->>'remark'"); // 非 Generated Column +} + +#[test] +fn test_filtered_query_uses_generated_column_for_sort() { + let generated_fields = vec!["code".to_string(), "level".to_string()]; + let (sql, _) = DynamicTableManager::build_filtered_query_sql_ex( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, 0, None, None, + Some("level".to_string()), + Some("asc".to_string()), + &generated_fields, + ).unwrap(); + + assert!( + sql.contains("ORDER BY \"_f_level\" ASC"), + "排序应使用 Generated Column,got: {}", sql + ); +} + +#[test] +fn test_filtered_query_uses_generated_column_for_filter() { + let generated_fields = vec!["status".to_string()]; + let (sql, _) = DynamicTableManager::build_filtered_query_sql_ex( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, 0, + Some(serde_json::json!({"status": "active"})), + None, None, None, + &generated_fields, + ).unwrap(); + + assert!( + sql.contains("\"_f_status\" = $"), + "过滤应使用 Generated Column,got: {}", sql + ); +} +``` + +- [ ] **Step 2: 验证失败** + +Run: `cargo test -p erp-plugin -- field_reference` +Expected: 编译失败 + +- [ ] **Step 3: 实现字段引用路由** + +在 `DynamicTableManager` 中添加: + +```rust +/// 返回字段引用函数的闭包 — Generated Column 存在时用 _f_{name},否则用 data->>'{name}' +pub fn field_reference_fn(generated_fields: &[String]) -> impl Fn(&str) -> String + '_ { + move |field_name: &str| { + let clean = sanitize_identifier(field_name); + if generated_fields.contains(&clean) { + format!("\"_f_{}\"", clean) + } else { + format!("\"data\"->>'{}'", clean) + } + } +} + +/// 扩展版查询构建 — 支持 Generated Column 路由 +pub fn build_filtered_query_sql_ex( + table_name: &str, + tenant_id: Uuid, + limit: u64, + offset: u64, + filter: Option, + search: Option<(String, String)>, + sort_by: Option, + sort_order: Option, + generated_fields: &[String], +) -> Result<(String, Vec), String> { + let ref_fn = Self::field_reference_fn(generated_fields); + + let mut conditions = vec![ + format!("\"tenant_id\" = ${}", 1), + "\"deleted_at\" IS NULL".to_string(), + ]; + let mut param_idx = 2; + let mut values: Vec = vec![tenant_id.into()]; + + // filter + if let Some(f) = filter { + if let Some(obj) = f.as_object() { + for (key, val) in obj { + let clean_key = sanitize_identifier(key); + if clean_key.is_empty() { + return Err(format!("无效的过滤字段名: {}", key)); + } + conditions.push(format!("{} = ${}", ref_fn(&clean_key), param_idx)); + values.push(Value::String(Some(Box::new( + val.as_str().unwrap_or("").to_string(), + )))); + param_idx += 1; + } + } + } + + // search(searchable 字段仍在 JSONB 中,使用 pg_trgm 索引) + if let Some((fields_csv, keyword)) = search { + let escaped = keyword.replace('%', "\\%").replace('_', "\\_"); + let fields: Vec<&str> = fields_csv.split(',').collect(); + let search_param_idx = param_idx; + let search_conditions: Vec = fields + .iter() + .map(|f| { + let clean = sanitize_identifier(f.trim()); + format!("\"data\"->>'{}' ILIKE ${}", clean, search_param_idx) + }) + .collect(); + conditions.push(format!("({})", search_conditions.join(" OR "))); + values.push(Value::String(Some(Box::new(format!("%{}%", escaped))))); + param_idx += 1; + } + + // sort + let order_clause = if let Some(sb) = sort_by { + let clean = sanitize_identifier(&sb); + if clean.is_empty() { + return Err(format!("无效的排序字段名: {}", sb)); + } + let dir = match sort_order.as_deref() { + Some("asc") | Some("ASC") => "ASC", + _ => "DESC", + }; + format!("ORDER BY {} {}", ref_fn(&clean), dir) + } else { + "ORDER BY \"created_at\" DESC".to_string() + }; + + let sql = format!( + "SELECT id, data, created_at, updated_at, version FROM \"{}\" WHERE {} {} LIMIT ${} OFFSET ${}", + table_name, + conditions.join(" AND "), + order_clause, + param_idx, + param_idx + 1, + ); + values.push((limit as i64).into()); + values.push((offset as i64).into()); + + Ok((sql, values)) +} +``` + +> 保持原有 `build_filtered_query_sql` 不变(无 generated_fields 参数版本,向后兼容),新方法 `_ex` 后缀。后续 Task 由 data_service 层传入 generated_fields。 + +- [ ] **Step 4: 运行测试** + +Run: `cargo test -p erp-plugin` +Expected: 全部 PASS + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-plugin/src/dynamic_table.rs +git commit -m "feat(plugin): SQL 查询路由 — Generated Column 字段优先使用 _f_ 前缀列" +``` + +--- + +### Task 1.5: dynamic_table.rs — Keyset Pagination + +**Files:** +- Modify: `crates/erp-plugin/src/dynamic_table.rs` +- Modify: `crates/erp-plugin/src/data_dto.rs:28-38` (PluginDataListParams) + +- [ ] **Step 1: 写失败测试** + +在 `dynamic_table.rs` tests 中添加: + +```rust +#[test] +fn test_keyset_cursor_encode_decode() { + let cursor = DynamicTableManager::encode_cursor( + &["测试值".to_string()], + &Uuid::parse_str("00000000-0000-0000-0000-000000000042").unwrap(), + ); + let decoded = DynamicTableManager::decode_cursor(&cursor).unwrap(); + assert_eq!(decoded.0, vec!["测试值"]); + assert_eq!(decoded.1, Uuid::parse_str("00000000-0000-0000-0000-000000000042").unwrap()); +} + +#[test] +fn test_keyset_sql_first_page() { + let (sql, _) = DynamicTableManager::build_keyset_query_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, + None, // 第一页无 cursor + Some("_f_name".to_string()), + "ASC", + &[], + ).unwrap(); + assert!(sql.contains("ORDER BY"), "应有 ORDER BY"); + assert!(!sql.contains(">"), "第一页不应有 cursor 条件"); +} + +#[test] +fn test_keyset_sql_with_cursor() { + let cursor = DynamicTableManager::encode_cursor( + &["Alice".to_string()], + &Uuid::parse_str("00000000-0000-0000-0000-000000000100").unwrap(), + ); + let (sql, values) = DynamicTableManager::build_keyset_query_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, + Some(cursor), + Some("_f_name".to_string()), + "ASC", + &[], + ).unwrap(); + assert!(sql.contains("ROW("), "cursor 条件应使用 ROW 比较"); + assert!(values.len() >= 4, "应有 tenant_id + cursor_val + cursor_id + limit"); +} +``` + +- [ ] **Step 2: 在 data_dto.rs 添加 cursor 字段** + +在 `PluginDataListParams` 中添加 `cursor` 字段: + +```rust +pub struct PluginDataListParams { + pub page: Option, + pub page_size: Option, + pub cursor: Option, // 新增:Base64 编码的游标(keyset pagination) + pub search: Option, + pub filter: Option, + pub sort_by: Option, + pub sort_order: Option, +} +``` + +- [ ] **Step 3: 实现 Keyset Pagination** + +在 `DynamicTableManager` 中添加: + +```rust +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; + +/// 编码游标:JSON { "v": [values], "id": "uuid" } → Base64 +pub fn encode_cursor(values: &[String], id: &Uuid) -> String { + let obj = serde_json::json!({ + "v": values, + "id": id.to_string(), + }); + BASE64.encode(obj.to_string()) +} + +/// 解码游标 +pub fn decode_cursor(cursor: &str) -> Result<(Vec, Uuid), String> { + let json_str = BASE64.decode(cursor) + .map_err(|e| format!("游标 Base64 解码失败: {}", e))?; + let obj: serde_json::Value = serde_json::from_slice(&json_str) + .map_err(|e| format!("游标 JSON 解析失败: {}", e))?; + let values = obj["v"].as_array() + .ok_or("游标缺少 v 字段")? + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect(); + let id = obj["id"].as_str() + .ok_or("游标缺少 id 字段")?; + let id = Uuid::parse_str(id).map_err(|e| format!("游标 id 解析失败: {}", e))?; + Ok((values, id)) +} + +/// 构建 Keyset 分页 SQL +pub fn build_keyset_query_sql( + table_name: &str, + tenant_id: Uuid, + limit: u64, + cursor: Option, + sort_column: Option, + sort_direction: &str, + generated_fields: &[String], +) -> Result<(String, Vec), String> { + let dir = match sort_direction { + "ASC" => "ASC", + _ => "DESC", + }; + let sort_col = sort_column + .as_deref() + .unwrap_or("\"created_at\""); + let sort_col_name = sort_column.as_deref().unwrap_or("created_at"); + + let mut values: Vec = vec![tenant_id.into()]; + let mut param_idx = 2; + + let cursor_condition = if let Some(c) = cursor { + let (sort_vals, cursor_id) = Self::decode_cursor(&c)?; + // ROW(sort_val, id) > ($N, $N+1) + let cond = format!( + "ROW({}, \"id\") {} (${}, ${})", + sort_col, + if dir == "ASC" { ">" } else { "<" }, + param_idx, param_idx + 1 + ); + values.push(Value::String(Some(Box::new( + sort_vals.first().cloned().unwrap_or_default() + )))); + values.push(cursor_id.into()); + param_idx += 2; + Some(cond) + } else { + None + }; + + let where_extra = cursor_condition + .map(|c| format!(" AND {}", c)) + .unwrap_or_default(); + + let sql = format!( + "SELECT id, data, created_at, updated_at, version FROM \"{}\" \ + WHERE \"tenant_id\" = $1 AND \"deleted_at\" IS NULL{} \ + ORDER BY {} {}, \"id\" {} LIMIT ${}", + table_name, where_extra, sort_col, dir, dir, param_idx, + ); + values.push((limit as i64).into()); + + Ok((sql, values)) +} +``` + +在 `Cargo.toml` 中添加 `base64` 依赖: + +```toml +base64 = { workspace = true } +``` + +> 如果 workspace 中没有 base64,则添加 `base64 = "0.22"` 到 `[dependencies]`。 + +- [ ] **Step 4: 运行测试** + +Run: `cargo test -p erp-plugin` +Expected: 全部 PASS + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-plugin/src/dynamic_table.rs crates/erp-plugin/src/data_dto.rs crates/erp-plugin/Cargo.toml +git commit -m "feat(plugin): Keyset Pagination — cursor 编解码 + 游标分页 SQL" +``` + +--- + +### Task 1.6: state.rs — Schema 缓存 (moka) + +**Files:** +- Modify: `crates/erp-plugin/src/state.rs` +- Modify: `crates/erp-server/src/state.rs` (**C1 修复:更新 FromRef 实现**) +- Modify: `crates/erp-plugin/Cargo.toml` + +- [ ] **Step 0: 将 `dynamic_table.rs` 中的 `sanitize_identifier` 改为 `pub(crate) fn`** + +```rust +// dynamic_table.rs — 将 fn sanitize_identifier 改为 pub(crate) fn +pub(crate) fn sanitize_identifier(input: &str) -> String { +``` + +同时在 `data_service.rs` 中将私有 `EntityInfo` 改为使用 `state::EntityInfo`(避免类型冲突): +- 删除 `data_service.rs` 中的私有 `struct EntityInfo` 和 `impl EntityInfo` +- 改为引用 `crate::state::EntityInfo` +- 将 `info.fields()` 改为 `fields_from_schema(&info.schema_json)` 辅助函数 + +```rust +/// 从 schema_json 解析字段列表 +fn fields_from_schema(schema_json: &serde_json::Value) -> AppResult> { + let entity_def: crate::manifest::PluginEntity = + serde_json::from_value(schema_json.clone()) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + Ok(entity_def.fields) +} +``` + +- [ ] **Step 1: 添加 moka 依赖** + +在 `crates/erp-plugin/Cargo.toml` 的 `[dependencies]` 中添加: + +```toml +moka = { version = "0.12", features = ["sync"] } +``` + +- [ ] **Step 2: 修改 PluginState** + +```rust +use moka::sync::Cache; +use std::time::Duration; + +use sea_orm::DatabaseConnection; +use erp_core::events::EventBus; +use crate::engine::PluginEngine; + +/// 插件模块共享状态 +#[derive(Clone)] +pub struct PluginState { + pub db: DatabaseConnection, + pub event_bus: EventBus, + pub engine: PluginEngine, + /// Schema 缓存 — key: "{plugin_id}:{entity_name}:{tenant_id}" + pub entity_cache: Cache, +} + +/// 缓存的实体信息 +#[derive(Clone, Debug)] +pub struct EntityInfo { + pub table_name: String, + pub schema_json: serde_json::Value, + pub generated_fields: Vec, +} + +impl Default for PluginState { + fn default() -> Self { + // 此方法仅用于测试;生产环境由 main.rs 构造 + unimplemented!("PluginState 应通过 new() 构造") + } +} + +impl PluginState { + pub fn new(db: DatabaseConnection, event_bus: EventBus, engine: PluginEngine) -> Self { + let entity_cache = Cache::builder() + .max_capacity(1000) + .time_to_idle(Duration::from_secs(300)) // TTL 5 分钟 + .build(); + Self { db, event_bus, engine, entity_cache } + } +} +``` + +**同时更新 `crates/erp-server/src/state.rs` 的 FromRef 实现:** + +```rust +impl FromRef for erp_plugin::state::PluginState { + fn from_ref(state: &AppState) -> Self { + Self { + db: state.db.clone(), + event_bus: state.event_bus.clone(), + engine: state.plugin_engine.clone(), + entity_cache: Cache::builder() // 每次 from_ref 都新建 Cache 不好 + .max_capacity(1000) // → 应将 cache 存入 AppState + .time_to_idle(Duration::from_secs(300)) + .build(), + } + } +} +``` + +> **更好的方案:** 将 `entity_cache` 放入 `AppState`,避免每次请求重建缓存: +> 1. 在 `AppState` 中添加 `pub plugin_entity_cache: moka::sync::Cache` +> 2. 在 `main.rs` 中初始化:`plugin_entity_cache: Cache::builder().max_capacity(1000).time_to_idle(Duration::from_secs(300)).build()` +> 3. `FromRef` 中传递 `state.plugin_entity_cache.clone()` + +**涉及文件更新:** +- `crates/erp-server/src/state.rs` — AppState 添加 cache 字段 + FromRef 更新 +- `crates/erp-server/src/main.rs` — AppState 初始化时构建 cache +``` + +- [ ] **Step 3: 更新 data_service.rs 使用缓存** + +在 `data_service.rs` 中修改 `resolve_entity_info` 方法签名,增加缓存参数: + +```rust +/// 从缓存或数据库获取实体信息 +pub async fn resolve_entity_info_cached( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + cache: &moka::sync::Cache, +) -> AppResult { + let cache_key = format!("{}:{}:{}", plugin_id, entity_name, tenant_id); + if let Some(info) = cache.get(&cache_key) { + return Ok(info); + } + + let entity = plugin_entity::Entity::find() + .filter(plugin_entity::Column::PluginId.eq(plugin_id)) + .filter(plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(plugin_entity::Column::EntityName.eq(entity_name)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| { + AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name)) + })?; + + // 解析 generated_fields + let entity_def: crate::manifest::PluginEntity = + serde_json::from_value(entity.schema_json.clone()) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + let generated_fields: Vec = entity_def.fields.iter() + .filter(|f| f.field_type.supports_generated_column()) + .filter(|f| { + f.unique || f.sortable == Some(true) || f.filterable == Some(true) + || (f.required && (f.sortable == Some(true) || f.filterable == Some(true))) + }) + .map(|f| crate::dynamic_table::sanitize_identifier(&f.name)) + .collect(); + + let info = crate::state::EntityInfo { + table_name: entity.table_name, + schema_json: entity.schema_json, + generated_fields, + }; + + cache.insert(cache_key, info.clone()); + Ok(info) +} +``` + +> **注意:** 需要将 `sanitize_identifier` 改为 `pub fn` 或新增一个 `pub fn sanitize_field_name` 包装。将 `dynamic_table.rs` 中的 `fn sanitize_identifier` 改为 `pub fn sanitize_identifier`。 + +- [ ] **Step 4: 编译验证** + +Run: `cargo check -p erp-plugin` +Expected: 编译通过 + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-plugin/src/state.rs crates/erp-plugin/src/data_service.rs crates/erp-plugin/src/dynamic_table.rs crates/erp-plugin/Cargo.toml +git commit -m "feat(plugin): Schema 缓存 — moka LRU Cache 消除 resolve_entity_info 重复查库" +``` + +--- + +### Task 1.7: data_service.rs — 聚合 Redis 缓存 + +**Files:** +- Modify: `crates/erp-plugin/src/data_service.rs` + +- [ ] **Step 1: 添加 Redis 缓存逻辑** + +在 `data_service.rs` 的 `aggregate` 方法中,新增 Redis 缓存层: + +```rust +/// 聚合查询(带 Redis 缓存) +pub async fn aggregate_cached( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + group_by_field: &str, + filter: Option, + redis: Option<&redis::Client>, +) -> AppResult> { + // 尝试从 Redis 读取 + if let Some(client) = redis { + let cache_key = format!( + "plugin:{}:{}:agg:{}:{}", + plugin_id, entity_name, group_by_field, tenant_id + ); + if let Ok(conn) = client.get_multiplexed_async_connection().await { + // Redis 读取逻辑(如命中则直接返回) + // 此处省略具体实现,下一 step 补全 + } + } + + // 回退到数据库查询 + Self::aggregate(plugin_id, entity_name, tenant_id, db, group_by_field, filter).await +} +``` + +> **注意:** Redis 缓存为可选增强。如果当前 `PluginState` 中没有 Redis 连接,此步骤可先定义接口,后续接入。设计规格中的 SLA 目标在缓存命中时 p50 < 50ms。 + +- [ ] **Step 2: 编译验证** + +Run: `cargo check -p erp-plugin` +Expected: 编译通过(Redis 为 optional 依赖) + +- [ ] **Step 3: 提交** + +```bash +git add crates/erp-plugin/src/data_service.rs +git commit -m "feat(plugin): 聚合查询 Redis 缓存骨架" +``` + +--- + +### Task 1.8: data_service.rs — list 方法集成 Generated Column 路由 + Keyset + +**Files:** +- Modify: `crates/erp-plugin/src/data_service.rs:76-165` (list 方法) + +- [ ] **Step 1: 修改 list 方法使用新的查询构建器** + +```rust +/// 列表查询(支持过滤/搜索/排序/游标分页) +pub async fn list( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + page: u64, + page_size: u64, + db: &sea_orm::DatabaseConnection, + cache: &moka::sync::Cache, + filter: Option, + search: Option, + sort_by: Option, + sort_order: Option, + cursor: Option, +) -> AppResult<(Vec, u64, Option)> { + let info = Self::resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?; + let entity_fields = info.fields()?; + + // searchable 字段列表 + let search_tuple = { + let searchable: Vec<&str> = entity_fields + .iter() + .filter(|f| f.searchable == Some(true)) + .map(|f| f.name.as_str()) + .collect(); + match (searchable.is_empty(), &search) { + (false, Some(kw)) => Some((searchable.join(","), kw.clone())), + _ => None, + } + }; + + // Count + let (count_sql, count_values) = + DynamicTableManager::build_count_sql(&info.table_name, tenant_id); + #[derive(FromQueryResult)] + struct CountResult { count: i64 } + let total = CountResult::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, count_sql, count_values, + )).one(db).await?.map(|r| r.count as u64).unwrap_or(0); + + // Query — 有 cursor 走 keyset,否则走 offset + let (sql, values) = if let Some(c) = cursor { + DynamicTableManager::build_keyset_query_sql( + &info.table_name, tenant_id, page_size, + Some(c), + sort_by.clone(), + sort_order.as_deref().unwrap_or("DESC"), + &info.generated_fields, + ).map_err(|e| AppError::Validation(e))? + } else { + DynamicTableManager::build_filtered_query_sql_ex( + &info.table_name, tenant_id, page_size, + page.saturating_sub(1) * page_size, + filter, search_tuple, sort_by, sort_order, + &info.generated_fields, + ).map_err(|e| AppError::Validation(e))? + }; + + #[derive(FromQueryResult)] + struct DataRow { + id: Uuid, + data: serde_json::Value, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + version: i32, + } + + let rows = DataRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, sql, values, + )).all(db).await?; + + // 生成下一页 cursor(W2 修复:编码排序字段值) + let next_cursor = rows.last().map(|r| { + let sort_val = if let Some(ref sb) = sort_by { + r.data.get(sb).and_then(|v| v.as_str()).unwrap_or("").to_string() + } else { + // 默认排序用 created_at 的字符串表示 + r.created_at.to_rfc3339() + }; + DynamicTableManager::encode_cursor(&[sort_val], &r.id) + }); + + let items = rows.into_iter().map(|r| PluginDataResp { + id: r.id.to_string(), + data: r.data, + created_at: Some(r.created_at), + updated_at: Some(r.updated_at), + version: Some(r.version), + }).collect(); + + Ok((items, total, next_cursor)) +} +``` + +- [ ] **Step 2: 更新 handler 调用** + +`data_handler.rs` 的 `list_plugin_data` 需要传入 `cache` 和 `cursor`,在后续 Chunk 中统一调整。 + +- [ ] **Step 3: 编译验证** + +Run: `cargo check -p erp-plugin` +Expected: 编译通过 + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-plugin/src/data_service.rs +git commit -m "feat(plugin): list 方法集成 Generated Column 路由 + Keyset Pagination" +``` + +--- + +### Task 1.9: CRM plugin.toml — 更新声明以利用 Generated Column + +**Files:** +- Modify: `crates/erp-plugin-crm/plugin.toml` + +- [ ] **Step 1: 确认当前字段声明已包含必要标记** + +CRM 的 `plugin.toml` 已正确声明了 `unique`, `filterable`, `sortable`, `searchable` 标记。`create_table` 方法会自动根据这些标记创建 Generated Column 和索引。**无需修改 plugin.toml。** + +验证:重新安装 CRM 插件后,`plugin_erp_crm_customer` 表应自动包含 `_f_code`, `_f_name`, `_f_customer_type`, `_f_status`, `_f_level` 等 Generated Column。 + +- [ ] **Step 2: 集成验证** + +Run: 启动后端服务 `cargo run -p erp-server`,调用 API 卸载+重新安装 CRM 插件 +Expected: 动态表包含 Generated Column,pg_trgm 索引已创建 + +- [ ] **Step 3: 提交(如有改动)** + +```bash +git add crates/erp-plugin-crm/plugin.toml +git commit -m "chore(crm): 验证 Generated Column 自动生成 — 无需修改 plugin.toml" +``` + +--- + +## Chunk 2: 数据完整性框架 + +### Task 2.1: manifest.rs — 扩展 PluginField (ref_entity / validation / no_cycle) + +**Files:** +- Modify: `crates/erp-plugin/src/manifest.rs:49-69` (PluginField struct) + +- [ ] **Step 1: 写失败测试** + +```rust +#[test] +fn parse_field_with_ref_entity() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "contact" +display_name = "联系人" + +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +required = true +display_name = "所属客户" +ref_entity = "customer" +"#; + let manifest = parse_manifest(toml).unwrap(); + let field = &manifest.schema.unwrap().entities[0].fields[0]; + assert_eq!(field.ref_entity.as_deref(), Some("customer")); +} + +#[test] +fn parse_field_with_validation() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "contact" +display_name = "联系人" + +[[schema.entities.fields]] +name = "phone" +field_type = "string" +display_name = "手机号" +validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" } +"#; + let manifest = parse_manifest(toml).unwrap(); + let field = &manifest.schema.unwrap().entities[0].fields[0]; + let v = field.validation.as_ref().unwrap(); + assert_eq!(v.pattern.as_deref(), Some("^1[3-9]\\d{9}$")); + assert_eq!(v.message.as_deref(), Some("手机号格式不正确")); +} + +#[test] +fn parse_field_with_no_cycle() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "customer" +display_name = "客户" + +[[schema.entities.fields]] +name = "parent_id" +field_type = "uuid" +display_name = "上级客户" +ref_entity = "customer" +no_cycle = true +"#; + let manifest = parse_manifest(toml).unwrap(); + let field = &manifest.schema.unwrap().entities[0].fields[0]; + assert_eq!(field.no_cycle, Some(true)); + assert_eq!(field.ref_entity.as_deref(), Some("customer")); +} +``` + +- [ ] **Step 2: 验证测试失败** + +Run: `cargo test -p erp-plugin -- parse_field_with_ref` +Expected: 编译失败(`ref_entity` 字段不存在) + +- [ ] **Step 3: 扩展 PluginField 结构体** + +在 `PluginField` 中新增三个字段: + +```rust +pub struct PluginField { + // ... 已有字段 ... + pub ref_entity: Option, // 外键引用的实体名 + pub validation: Option, // 字段校验规则 + pub no_cycle: Option, // 禁止循环引用 +} + +/// 字段校验规则 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldValidation { + pub pattern: Option, // 正则表达式 + pub message: Option, // 校验失败提示 +} +``` + +同时更新 `#[cfg(test)]` 中的 `default_for_field()` 辅助方法: + +```rust +pub fn default_for_field() -> Self { + Self { + // ... 已有字段 ... + ref_entity: None, + validation: None, + no_cycle: None, + } +} +``` + +- [ ] **Step 4: 运行测试** + +Run: `cargo test -p erp-plugin` +Expected: 全部 PASS + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-plugin/src/manifest.rs +git commit -m "feat(plugin): PluginField 扩展 — ref_entity / validation / no_cycle" +``` + +--- + +### Task 2.2: manifest.rs — 新增 PluginRelation (级联删除声明) + +**Files:** +- Modify: `crates/erp-plugin/src/manifest.rs` (PluginEntity + OnDeleteStrategy) + +- [ ] **Step 1: 写失败测试** + +```rust +#[test] +fn parse_entity_with_relations() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "customer" +display_name = "客户" + +[[schema.entities.fields]] +name = "code" +field_type = "string" +required = true +display_name = "编码" + +[[schema.entities.relations]] +entity = "contact" +foreign_key = "customer_id" +on_delete = "cascade" + +[[schema.entities.relations]] +entity = "customer_tag" +foreign_key = "customer_id" +on_delete = "cascade" +"#; + let manifest = parse_manifest(toml).unwrap(); + let entity = &manifest.schema.unwrap().entities[0]; + assert_eq!(entity.relations.len(), 2); + assert_eq!(entity.relations[0].entity, "contact"); + assert_eq!(entity.relations[0].foreign_key, "customer_id"); + assert!(matches!(entity.relations[0].on_delete, OnDeleteStrategy::Cascade)); +} +``` + +- [ ] **Step 2: 实现 PluginRelation + OnDeleteStrategy** + +```rust +/// 级联删除策略 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum OnDeleteStrategy { + Nullify, // 置空外键字段 + Cascade, // 级联软删除 + Restrict, // 存在关联时拒绝删除 +} + +/// 实体关联关系声明 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginRelation { + pub entity: String, // 关联实体名 + pub foreign_key: String, // 关联实体中的外键字段名 + pub on_delete: OnDeleteStrategy, // 级联策略 +} +``` + +在 `PluginEntity` 中添加: + +```rust +pub struct PluginEntity { + pub name: String, + pub display_name: String, + #[serde(default)] + pub fields: Vec, + #[serde(default)] + pub indexes: Vec, + #[serde(default)] + pub relations: Vec, // 新增 +} +``` + +- [ ] **Step 3: 运行测试** + +Run: `cargo test -p erp-plugin -- parse_entity_with_relations` +Expected: PASS + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-plugin/src/manifest.rs +git commit -m "feat(plugin): PluginRelation 级联删除声明 + OnDeleteStrategy" +``` + +--- + +### Task 2.3: data_service.rs — 外键校验 (ref_entity) + +**Files:** +- Modify: `crates/erp-plugin/src/data_service.rs` (validate_data 扩展) + +- [ ] **Step 1: 写失败测试** + +```rust +#[tokio::test] +async fn test_validate_ref_entity_rejects_missing_reference() { + // 简单验证逻辑单元测试:ref_entity 字段指向不存在的记录时应报错 + // 注意:此测试需要数据库连接,标记为集成测试或使用 mock + // 此处先验证 validate_ref_entities 函数签名存在 +} +``` + +> 由于外键校验需要数据库查询,此任务更适合集成测试。此处先实现逻辑,集成测试在手动验证阶段进行。 + +- [ ] **Step 2: 实现 ref_entity 校验函数** + +在 `data_service.rs` 中新增: + +```rust +/// 校验外键引用 — 检查 ref_entity 字段指向的记录是否存在 +async fn validate_ref_entities( + data: &serde_json::Value, + fields: &[PluginField], + current_entity: &str, + plugin_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + is_create: bool, + record_id: Option, +) -> AppResult<()> { + let obj = data.as_object().ok_or_else(|| { + AppError::Validation("data 必须是 JSON 对象".to_string()) + })?; + + for field in fields { + let Some(ref_entity_name) = &field.ref_entity else { continue }; + + let Some(val) = obj.get(&field.name) else { continue }; + let str_val = val.as_str().unwrap_or("").trim().to_string(); + + // null 或空字符串且非 required → 跳过 + if str_val.is_empty() && !field.required { + continue; + } + if str_val.is_empty() { + continue; + } + + // 解析 UUID + let ref_id = Uuid::parse_str(&str_val).map_err(|_| { + AppError::Validation(format!( + "字段 '{}' 的值 '{}' 不是有效的 UUID", + field.display_name.as_deref().unwrap_or(&field.name), + str_val + )) + })?; + + // 自引用 + create:如果引用的是自身 ID,跳过(记录尚不存在) + if ref_entity_name == current_entity && is_create { + if let Some(rid) = record_id { + if ref_id == rid { + continue; + } + } else { + // create 时无 record_id,自引用跳过 + continue; + } + } + + // 查询被引用记录是否存在 + let ref_table = DynamicTableManager::table_name( + &resolve_manifest_id(plugin_id, tenant_id, db).await?, + ref_entity_name, + ); + + let check_sql = format!( + "SELECT 1 FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1", + ref_table + ); + let result: Option<(i32,)> = sqlx::query_as(&check_sql) + .bind(ref_id) + .bind(tenant_id) + .fetch_optional(db) + .await + .map_err(|e| AppError::Internal(format!("外键校验查询失败: {}", e)))?; + + if result.is_none() { + return Err(AppError::Validation(format!( + "引用的 {} 记录不存在(ID: {})", + ref_entity_name, ref_id + ))); + } + } + + Ok(()) +} +``` + +> **注意:** 上述代码使用了 `sqlx` 风格,但项目使用 `sea_orm`。实际实现应使用 `Statement::from_sql_and_values`: + +```rust +#[derive(FromQueryResult)] +struct ExistsCheck { exists: Option } + +let check_sql = format!( + "SELECT 1 as exists FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1", + ref_table +); +let result = ExistsCheck::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + check_sql, + [ref_id.into(), tenant_id.into()], +)).one(db).await?; + +if result.is_none() { + return Err(AppError::Validation(format!( + "引用的 {} 记录不存在(ID: {})", + ref_entity_name, ref_id + ))); +} +``` + +- [ ] **Step 3: 集成到 create/update 方法** + +在 `create` 方法的 `validate_data` 调用之后添加: + +```rust +validate_ref_entities(&data, &fields, entity_name, plugin_id, tenant_id, db, true, None).await?; +``` + +在 `update` 方法中添加: + +```rust +validate_ref_entities(&data, &fields, entity_name, plugin_id, tenant_id, db, false, Some(id)).await?; +``` + +- [ ] **Step 4: 编译验证** + +Run: `cargo check -p erp-plugin` +Expected: 编译通过 + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-plugin/src/data_service.rs +git commit -m "feat(plugin): 外键校验 — ref_entity 字段验证引用记录存在性" +``` + +--- + +### Task 2.4: data_service.rs — 字段校验 (validation.pattern) + +**Files:** +- Modify: `crates/erp-plugin/src/data_service.rs` (validate_data 扩展) +- Modify: `crates/erp-plugin/Cargo.toml` (添加 regex 依赖) + +- [ ] **Step 1: 添加 regex 依赖** + +在 `Cargo.toml` 中添加: + +```toml +regex = "1" +``` + +- [ ] **Step 2: 扩展 validate_data** + +修改 `validate_data` 函数,在 required 检查之后增加正则校验: + +```rust +fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> { + let obj = data.as_object().ok_or_else(|| { + AppError::Validation("data 必须是 JSON 对象".to_string()) + })?; + + for field in fields { + let label = field.display_name.as_deref().unwrap_or(&field.name); + + // required 检查 + if field.required && !obj.contains_key(&field.name) { + return Err(AppError::Validation(format!("字段 '{}' 不能为空", label))); + } + + // 正则校验 + if let Some(validation) = &field.validation { + if let Some(pattern) = &validation.pattern { + if let Some(val) = obj.get(&field.name) { + let str_val = val.as_str().unwrap_or(""); + if !str_val.is_empty() { + let re = regex::Regex::new(pattern) + .map_err(|e| AppError::Internal(format!("正则表达式编译失败: {}", e)))?; + if !re.is_match(str_val) { + let msg = validation.message.as_deref() + .unwrap_or(&format!("字段 '{}' 格式不正确", label)); + return Err(AppError::Validation(msg.to_string())); + } + } + } + } + } + } + + Ok(()) +} +``` + +- [ ] **Step 3: 写单元测试** + +```rust +#[cfg(test)] +mod validate_tests { + use super::*; + use crate::manifest::{FieldValidation, PluginField, PluginFieldType}; + + fn make_field(name: &str, pattern: Option<&str>, message: Option<&str>) -> PluginField { + PluginField { + name: name.to_string(), + field_type: PluginFieldType::String, + required: false, + validation: pattern.map(|p| FieldValidation { + pattern: Some(p.to_string()), + message: message.map(|m| m.to_string()), + }), + ..PluginField::default_for_field() + } + } + + #[test] + fn validate_phone_pattern_rejects_invalid() { + let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), Some("手机号格式不正确"))]; + let data = serde_json::json!({"phone": "1234"}); + let result = validate_data(&data, &fields); + assert!(result.is_err()); + } + + #[test] + fn validate_phone_pattern_accepts_valid() { + let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), Some("手机号格式不正确"))]; + let data = serde_json::json!({"phone": "13812345678"}); + let result = validate_data(&data, &fields); + assert!(result.is_ok()); + } + + #[test] + fn validate_empty_optional_field_skips_pattern() { + let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), None)]; + let data = serde_json::json!({"phone": ""}); + let result = validate_data(&data, &fields); + assert!(result.is_ok()); // 空值 + 非 required → 跳过校验 + } +} +``` + +- [ ] **Step 4: 运行测试** + +Run: `cargo test -p erp-plugin -- validate` +Expected: 全部 PASS + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-plugin/src/data_service.rs crates/erp-plugin/Cargo.toml +git commit -m "feat(plugin): 字段正则校验 — validation.pattern 支持" +``` + +--- + +### Task 2.5: data_service.rs — 循环引用检测 (no_cycle) + +**Files:** +- Modify: `crates/erp-plugin/src/data_service.rs` + +- [ ] **Step 1: 写失败测试** + +```rust +#[test] +fn detect_cycle_in_self_reference() { + // 循环检测是纯逻辑:A.parent_id = B, B.parent_id = A → 检测到循环 + // 需要数据库查询链,此处测试 detect_cycle 函数签名 +} +``` + +> 循环检测需要数据库交互,此处直接实现,手动集成测试验证。 + +- [ ] **Step 2: 实现循环检测** + +在 `data_service.rs` 中新增: + +```rust +/// 循环引用检测 — 用于 no_cycle 字段 +/// 从当前记录开始,沿 ref_entity 链向上追溯,检测是否形成环 +async fn check_no_cycle( + record_id: Uuid, + field: &PluginField, + data: &serde_json::Value, + table_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> AppResult<()> { + let Some(val) = data.get(&field.name) else { return Ok(()) }; + let new_parent = val.as_str().unwrap_or("").trim().to_string(); + if new_parent.is_empty() { + return Ok(()); + } + let new_parent_id = Uuid::parse_str(&new_parent).map_err(|_| { + AppError::Validation("parent_id 不是有效的 UUID".to_string()) + })?; + + let field_name = sanitize_identifier(&field.name); + let mut visited = vec![record_id]; + let mut current_id = new_parent_id; + + // 最多追溯 100 层,防止无限循环 + for _ in 0..100 { + if visited.contains(¤t_id) { + let label = field.display_name.as_deref().unwrap_or(&field.name); + return Err(AppError::Validation(format!( + "字段 '{}' 形成循环引用", label + ))); + } + visited.push(current_id); + + // 查询 current 的 parent_id + let query_sql = format!( + "SELECT data->>'{}' as parent FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL", + field_name, table_name + ); + #[derive(FromQueryResult)] + struct ParentRow { parent: Option } + let row = ParentRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + query_sql, + [current_id.into(), tenant_id.into()], + )).one(db).await?; + + match row { + Some(r) => { + let parent = r.parent.unwrap_or_default().trim().to_string(); + if parent.is_empty() { + break; // 到达根节点 + } + current_id = Uuid::parse_str(&parent).map_err(|_| { + AppError::Internal("parent_id 不是有效的 UUID".to_string()) + })?; + } + None => break, // 记录不存在或已删除 + } + } + + Ok(()) +} +``` + +- [ ] **Step 3: 集成到 update 方法** + +在 `update` 方法中,`validate_ref_entities` 之后添加: + +```rust +// 循环引用检测 +for field in &fields { + if field.no_cycle == Some(true) && data.get(&field.name).is_some() { + check_no_cycle(id, field, &data, &info.table_name, tenant_id, db).await?; + } +} +``` + +- [ ] **Step 4: 编译验证** + +Run: `cargo check -p erp-plugin` +Expected: 编译通过 + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-plugin/src/data_service.rs +git commit -m "feat(plugin): 循环引用检测 — no_cycle 字段支持" +``` + +--- + +### Task 2.6: data_service.rs — 级联删除 (relations) + +**Files:** +- Modify: `crates/erp-plugin/src/data_service.rs` (delete 方法) + +- [ ] **Step 1: 实现级联删除** + +修改 `delete` 方法: + +```rust +/// 删除(软删除)— 支持级联策略 +pub async fn delete( + plugin_id: Uuid, + entity_name: &str, + id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + cache: &moka::sync::Cache, + event_bus: &EventBus, +) -> AppResult<()> { + let info = Self::resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?; + let entity_def: crate::manifest::PluginEntity = + serde_json::from_value(info.schema_json.clone()) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + + // 处理级联关系 + let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?; + for relation in &entity_def.relations { + let rel_table = DynamicTableManager::table_name(&manifest_id, &relation.entity); + let fk = sanitize_identifier(&relation.foreign_key); + + match relation.on_delete { + OnDeleteStrategy::Restrict => { + // 检查是否有引用 + let check_sql = format!( + "SELECT 1 FROM \"{}\" WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1", + rel_table, fk + ); + #[derive(FromQueryResult)] + struct RefCheck { exists: Option } + let has_ref = RefCheck::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + check_sql, + [id.to_string().into(), tenant_id.into()], + )).one(db).await?; + if has_ref.is_some() { + return Err(AppError::Validation(format!( + "存在关联的 {} 记录,无法删除", + relation.entity + ))); + } + } + OnDeleteStrategy::Nullify => { + // 将关联记录的外键置空 + let nullify_sql = format!( + "UPDATE \"{}\" SET data = data || {{\\\"{}\\\": null}}, updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL", + rel_table, fk, fk + ); + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + nullify_sql, + [id.to_string().into(), tenant_id.into()], + )).await?; + } + OnDeleteStrategy::Cascade => { + // 级联软删除(深度上限 3 层,此处只处理第 1 层) + let cascade_sql = format!( + "UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL", + rel_table, fk + ); + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + cascade_sql, + [id.to_string().into(), tenant_id.into()], + )).await?; + } + } + } + + // 软删除主记录 + let (sql, values) = DynamicTableManager::build_delete_sql(&info.table_name, id, tenant_id); + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )).await?; + + Ok(()) +} +``` + +- [ ] **Step 2: 编译验证** + +Run: `cargo check -p erp-plugin` +Expected: 编译通过 + +- [ ] **Step 3: 提交** + +```bash +git add crates/erp-plugin/src/data_service.rs +git commit -m "feat(plugin): 级联删除 — Restrict/Nullify/Cascade 三种策略" +``` + +--- + +### Task 2.7: CRM plugin.toml — 添加 ref_entity / relations / validation / no_cycle + +**Files:** +- Modify: `crates/erp-plugin-crm/plugin.toml` + +- [ ] **Step 1: 更新 contact 实体 — customer_id 添加 ref_entity** + +将 contact 的 customer_id 字段改为: + +```toml +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +required = true +display_name = "所属客户" +ref_entity = "customer" +``` + +- [ ] **Step 2: 更新 communication 实体 — 添加 ref_entity** + +```toml +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +required = true +display_name = "关联客户" +ref_entity = "customer" + +[[schema.entities.fields]] +name = "contact_id" +field_type = "uuid" +display_name = "关联联系人" +ref_entity = "contact" +``` + +- [ ] **Step 3: 更新 customer_tag / customer_relationship — 添加 ref_entity** + +```toml +# customer_tag +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +required = true +display_name = "关联客户" +ref_entity = "customer" + +# customer_relationship +[[schema.entities.fields]] +name = "from_customer_id" +field_type = "uuid" +required = true +display_name = "源客户" +ref_entity = "customer" + +[[schema.entities.fields]] +name = "to_customer_id" +field_type = "uuid" +required = true +display_name = "目标客户" +ref_entity = "customer" +``` + +- [ ] **Step 4: customer 实体 — 添加 relations + no_cycle + validation** + +```toml +# customer 的 parent_id 字段添加 ref_entity + no_cycle +[[schema.entities.fields]] +name = "parent_id" +field_type = "uuid" +display_name = "上级客户" +ref_entity = "customer" +no_cycle = true + +# customer 实体的 relations +[[schema.entities.relations]] +entity = "contact" +foreign_key = "customer_id" +on_delete = "nullify" + +[[schema.entities.relations]] +entity = "communication" +foreign_key = "customer_id" +on_delete = "cascade" + +[[schema.entities.relations]] +entity = "customer_tag" +foreign_key = "customer_id" +on_delete = "cascade" +``` + +contact 的 phone/email 添加 validation: + +```toml +[[schema.entities.fields]] +name = "phone" +field_type = "string" +display_name = "手机号" +validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" } + +[[schema.entities.fields]] +name = "email" +field_type = "string" +display_name = "邮箱" +validation = { pattern = "^[\\w.-]+@[\\w.-]+\\.\\w+$", message = "邮箱格式不正确" } +``` + +- [ ] **Step 5: 验证 TOML 解析** + +Run: `cargo test -p erp-plugin -- parse_full_manifest` +Expected: PASS + +- [ ] **Step 6: 提交** + +```bash +git add crates/erp-plugin-crm/plugin.toml +git commit -m "feat(crm): 添加 ref_entity / relations / validation / no_cycle 声明" +``` + +--- + +## Chunk 3: 行级数据权限 + +### Task 3.1: 数据库迁移 — role_permissions 添加 data_scope 列 + 权限补偿 + +**Files:** +- Create: `crates/erp-server/migration/src/m20260418_000036_add_data_scope_to_role_permissions.rs` +- Modify: `crates/erp-server/migration/src/lib.rs` + +- [ ] **Step 1: 创建迁移文件** + +```rust +// crates/erp-server/migration/src/m20260418_000036_add_data_scope_to_role_permissions.rs +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> { + // 添加 data_scope 列 + manager + .alter_table( + Table::alter() + .table(Alias::new("role_permissions")) + .add_column( + ColumnDef::new(Alias::new("data_scope")) + .string() + .not_null() + .default("all"), + ) + .to_owned(), + ) + .await?; + + // 权限补偿:为所有拥有 plugin.admin 权限的角色,自动分配所有已安装插件的实体级权限 + // data_scope 默认 'all'(管理员级别) + let conn = manager.get_connection(); + conn.execute_unprepared( + r#" + INSERT INTO role_permissions (id, role_id, permission_id, tenant_id, data_scope, created_at, updated_at) + SELECT gen_random_uuid(), rp.role_id, p.id, rp.tenant_id, 'all', NOW(), NOW() + FROM role_permissions rp + JOIN permissions p ON p.tenant_id = rp.tenant_id + JOIN role_permissions rp_src ON rp_src.role_id = rp.role_id + JOIN permissions p_src ON p_src.id = rp.permission_id AND p_src.code = 'plugin.admin' + WHERE p.code LIKE 'erp-%' + AND NOT EXISTS ( + SELECT 1 FROM role_permissions rp2 + WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p.id + ) + GROUP BY rp.role_id, p.id, rp.tenant_id + "# + ).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("role_permissions")) + .drop_column(Alias::new("data_scope")) + .to_owned(), + ) + .await + } +} +``` + +- [ ] **Step 2: 注册迁移** + +在 `lib.rs` 中添加 `mod m20260418_000036_add_data_scope_to_role_permissions;` 和对应的 `Box::new(...)`。 + +- [ ] **Step 3: 验证** + +Run: `cargo check -p erp-server` +Expected: 编译通过 + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-server/migration/src/m20260418_000036_add_data_scope_to_role_permissions.rs crates/erp-server/migration/src/lib.rs +git commit -m "feat(db): role_permissions 添加 data_scope 列 + plugin.admin 权限补偿" +``` + +--- + +### Task 3.2: erp-core — TenantContext 扩展 department_ids + +**Files:** +- Modify: `crates/erp-core/src/types.rs:150-158` (TenantContext) + +- [ ] **Step 1: 写失败测试** + +```rust +#[test] +fn tenant_context_with_departments() { + let ctx = TenantContext { + tenant_id: Uuid::now_v7(), + user_id: Uuid::now_v7(), + roles: vec!["admin".to_string()], + permissions: vec!["user.read".to_string()], + department_ids: vec![Uuid::now_v7(), Uuid::now_v7()], + }; + assert_eq!(ctx.department_ids.len(), 2); +} +``` + +- [ ] **Step 2: 修改 TenantContext** + +```rust +/// 租户上下文(中间件注入) +#[derive(Debug, Clone)] +pub struct TenantContext { + pub tenant_id: Uuid, + pub user_id: Uuid, + pub roles: Vec, + pub permissions: Vec, + pub department_ids: Vec, // 新增:用户所属部门 ID 列表 +} +``` + +- [ ] **Step 3: 修复现有测试中所有 TenantContext 构造** + +所有测试代码中构造 `TenantContext` 的地方需添加 `department_ids: vec![]`。 + +Run: `cargo test -p erp-core` +Expected: 全部 PASS + +- [ ] **Step 4: 修复所有 crate 中的 TenantContext 构造** + +在以下文件中搜索 `TenantContext {` 并添加 `department_ids: vec![]`: +- `crates/erp-auth/src/middleware/jwt_auth.rs` — JWT claims 解析时填充 department_ids +- `crates/erp-plugin/src/handler/data_handler.rs` — 无构造,仅使用 +- 其他 handler 文件 + +Run: `cargo check --workspace` +Expected: 全 workspace 编译通过 + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-core/src/types.rs crates/erp-auth/src/middleware/jwt_auth.rs +git commit -m "feat(core): TenantContext 新增 department_ids 字段" +``` + +--- + +### Task 3.3: erp-auth — JWT 中间件填充 department_ids + +**Files:** +- Modify: `crates/erp-auth/src/middleware/jwt_auth.rs` + +- [ ] **Step 1: 修改 JWT claims 解析** + +在 JWT 中间件中,从 token claims 或数据库查询用户的部门 ID: + +```rust +// 在构造 TenantContext 时,查询用户所属部门 +let department_ids = { + // 通过 user_positions 表关联 departments 表查询 + let positions = user_position::Entity::find() + .filter(user_position::Column::UserId.eq(user_id)) + .filter(user_position::Column::TenantId.eq(tenant_id)) + .all(db) + .await + .unwrap_or_default(); + positions.iter().map(|p| p.department_id).collect() +}; + +TenantContext { + tenant_id, + user_id, + roles, + permissions, + department_ids, +} +``` + +> **注意:** 具体查询逻辑取决于 `erp-auth` 中 Position/Department 的 SeaORM Entity 定义。需先确认 entity 路径。 + +- [ ] **Step 2: 编译验证** + +Run: `cargo check --workspace` +Expected: 编译通过 + +- [ ] **Step 3: 提交** + +```bash +git add crates/erp-auth/src/middleware/jwt_auth.rs +git commit -m "feat(auth): JWT 中间件填充 department_ids" +``` + +--- + +### Task 3.4: manifest.rs — 实体级 data_scope 声明 + +**Files:** +- Modify: `crates/erp-plugin/src/manifest.rs` (PluginEntity, PluginField, PluginPermission) + +- [ ] **Step 1: 写失败测试** + +```rust +#[test] +fn parse_entity_with_data_scope() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "customer" +display_name = "客户" +data_scope = true + +[[schema.entities.fields]] +name = "owner_id" +field_type = "uuid" +display_name = "负责人" +scope_role = "owner" + +[[permissions]] +code = "customer.list" +name = "查看客户" +data_scope_levels = ["self", "department", "department_tree", "all"] +"#; + let manifest = parse_manifest(toml).unwrap(); + let entity = &manifest.schema.unwrap().entities[0]; + assert_eq!(entity.data_scope, Some(true)); + assert_eq!(entity.fields[0].scope_role.as_deref(), Some("owner")); + + let perm = &manifest.permissions.unwrap()[0]; + assert_eq!(perm.data_scope_levels.as_ref().unwrap().len(), 4); +} +``` + +- [ ] **Step 2: 扩展结构体** + +```rust +pub struct PluginEntity { + pub name: String, + pub display_name: String, + pub fields: Vec, + pub indexes: Vec, + pub relations: Vec, + pub data_scope: Option, // 新增:是否启用行级数据权限 +} + +pub struct PluginField { + // ... 已有字段 ... + pub scope_role: Option, // 新增:标记为数据权限的"所有者"字段 +} + +pub struct PluginPermission { + pub code: String, + pub name: String, + pub description: String, + pub data_scope_levels: Option>, // 新增:支持的数据范围等级 +} +``` + +- [ ] **Step 3: 运行测试** + +Run: `cargo test -p erp-plugin -- parse_entity_with_data_scope` +Expected: PASS + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-plugin/src/manifest.rs +git commit -m "feat(plugin): 实体级 data_scope + scope_role + data_scope_levels 声明" +``` + +--- + +### Task 3.5: dynamic_table.rs — SQL 构建支持数据范围条件 + +**Files:** +- Modify: `crates/erp-plugin/src/dynamic_table.rs` + +- [ ] **Step 1: 写失败测试** + +```rust +#[test] +fn test_build_data_scope_condition_self() { + let condition = DynamicTableManager::build_data_scope_condition( + "self", + &Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "owner_id", + &[], + ); + assert!(condition.contains("owner_id"), "self 应包含 owner_id 条件"); +} + +#[test] +fn test_build_data_scope_condition_department() { + let dept_members = vec![ + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(), + ]; + let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params( + "department", + &Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "owner_id", + &dept_members, + 2, // 起始参数索引 + ); + assert!(sql.contains("IN"), "department 应使用 IN 条件"); + assert!(sql.contains("$2"), "参数索引应正确"); +} +``` + +- [ ] **Step 2: 实现数据范围 SQL 构建** + +```rust +/// 构建数据范围 SQL 条件 +/// 返回 (条件 SQL, 附加参数) +pub fn build_data_scope_condition_with_params( + scope_level: &str, + current_user_id: &Uuid, + owner_field: &str, + dept_member_ids: &[Uuid], + start_param_idx: usize, + generated_fields: &[String], // C4 修复:接受 generated_fields 参数 +) -> (String, Vec) { + let ref_fn = DynamicTableManager::field_reference_fn(generated_fields); + let owner_ref = ref_fn(owner_field); // 如果 owner_id 是 Generated Column,使用 _f_owner_id + match scope_level { + "self" => ( + format!("({} = ${} OR \"created_by\" = ${})", owner_ref, start_param_idx, start_param_idx), + vec![current_user_id.to_string().into(), (*current_user_id).into()], + ), + "department" | "department_tree" => { + if dept_member_ids.is_empty() { + // 无部门 → 降级为 self + ( + format!("({} = ${} OR \"created_by\" = ${})", owner_ref, start_param_idx, start_param_idx), + vec![current_user_id.to_string().into(), (*current_user_id).into()], + ) + } else { + let placeholders: Vec = dept_member_ids + .iter() + .enumerate() + .map(|(i, _)| format!("${}", start_param_idx + i)) + .collect(); + let values: Vec = dept_member_ids + .iter() + .map(|id| id.to_string().into()) + .collect(); + ( + format!("{} IN ({})", owner_ref, placeholders.join(", ")), + values, + ) + } + } + "all" | _ => (String::new(), vec![]), // all → 无额外条件 + } +} +``` + +- [ ] **Step 3: 运行测试** + +Run: `cargo test -p erp-plugin -- test_build_data_scope` +Expected: PASS + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-plugin/src/dynamic_table.rs +git commit -m "feat(plugin): SQL 构建支持行级数据范围条件" +``` + +--- + +### Task 3.6: data_handler.rs — 移除权限 fallback + +**Files:** +- Modify: `crates/erp-plugin/src/handler/data_handler.rs` + +- [ ] **Step 1: 修改权限检查逻辑** + +将所有 handler 中的两级 fallback 改为单级检查: + +**当前(危险):** +```rust +let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); +if require_permission(&ctx, &fine_perm).is_err() { + require_permission(&ctx, "plugin.list")?; +} +``` + +**修改后:** +```rust +let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); +require_permission(&ctx, &fine_perm)?; +``` + +对所有 handler 方法(list, create, get, update, delete, count, aggregate)执行相同的修改: + +- `create` 和 `update` 和 `delete`:删除 `plugin.admin` fallback +- `list` 和 `get` 和 `count` 和 `aggregate`:删除 `plugin.list` fallback + +- [ ] **Step 2: 编译验证** + +Run: `cargo check -p erp-plugin` +Expected: 编译通过 + +- [ ] **Step 3: 提交** + +```bash +git add crates/erp-plugin/src/handler/data_handler.rs +git commit -m "fix(plugin): 移除权限 fallback — 必须显式分配实体级权限" +``` + +--- + +### Task 3.7: data_service.rs — 查询注入数据范围 + +**Files:** +- Modify: `crates/erp-plugin/src/data_service.rs` (list/count/aggregate 方法) + +- [ ] **Step 1: 在 list 方法中注入数据范围条件** + +**Handler 层(data_handler.rs)— 获取 data_scope 等级并查询部门用户:** + +```rust +/// 获取当前用户对指定权限的 data_scope 等级 +async fn get_data_scope( + ctx: &TenantContext, + permission_code: &str, + db: &DatabaseConnection, +) -> AppResult { + #[derive(FromQueryResult)] + struct ScopeResult { data_scope: Option } + + let result = ScopeResult::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"SELECT rp.data_scope + FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = $1 AND ur.tenant_id = $2 AND p.code = $3 + LIMIT 1"#, + [ctx.user_id.into(), ctx.tenant_id.into(), permission_code.into()], + )).one(db).await.map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(result.and_then(|r| r.data_scope).unwrap_or("all".to_string())) +} + +/// 获取部门成员 ID 列表(含下级部门) +async fn get_dept_members( + ctx: &TenantContext, + db: &DatabaseConnection, + include_sub_depts: bool, +) -> AppResult> { + if ctx.department_ids.is_empty() { + return Ok(vec![]); + } + + let dept_ids = if include_sub_depts { + // 递归查询部门树:先获取所有下级部门 + let dept_list = ctx.department_ids.iter() + .map(|id| id.to_string()) + .collect::>() + .join("','"); + #[derive(FromQueryResult)] + struct DeptId { id: Uuid } + let sql = format!( + r#"WITH RECURSIVE dept_tree AS ( + SELECT id FROM departments WHERE id IN ('{}') AND tenant_id = $1 + UNION ALL + SELECT d.id FROM departments d JOIN dept_tree dt ON d.parent_id = dt.id + ) SELECT id FROM dept_tree"#, + dept_list + ); + let rows = DeptId::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [ctx.tenant_id.into()], + )).all(db).await?; + rows.into_iter().map(|r| r.id).collect() + } else { + ctx.department_ids.clone() + }; + + // 查询这些部门中的用户 ID(通过 positions 表) + let dept_list = dept_ids.iter().map(|id| id.to_string()) + .collect::>() + .join("','"); + #[derive(FromQueryResult)] + struct UserId { user_id: Uuid } + let sql = format!( + "SELECT DISTINCT user_id FROM positions WHERE dept_id IN ('{}') AND tenant_id = $1", + dept_list + ); + let rows = UserId::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [ctx.tenant_id.into()], + )).all(db).await?; + Ok(rows.into_iter().map(|r| r.user_id).collect()) +} +``` + +**在 `list_plugin_data` handler 中调用:** + +```rust +// 获取 data_scope 等级 +let data_scope = get_data_scope(&ctx, &fine_perm, &state.db).await?; + +// 获取部门成员(仅 department/department_tree 级别需要) +let dept_members = match data_scope.as_str() { + "department" => get_dept_members(&ctx, &state.db, false).await?, + "department_tree" => get_dept_members(&ctx, &state.db, true).await?, + _ => vec![], +}; + +// 传入 data_service +let (items, total, next_cursor) = PluginDataService::list_with_scope( + plugin_id, &entity, ctx.tenant_id, page, page_size, + &state.db, &state.entity_cache, + filter, params.search, params.sort_by, params.sort_order, params.cursor, + &data_scope, &ctx.user_id, &dept_members, +).await?; +``` + +**data_service.rs 新增 `list_with_scope` 方法:** + +在 `build_filtered_query_sql_ex` 的条件列表中追加 data_scope 条件: + +```rust +pub async fn list_with_scope( + /* ... 原有参数 ... */ + data_scope: &str, + current_user_id: &Uuid, + dept_members: &[Uuid], +) -> AppResult<(Vec, u64, Option)> { + let info = Self::resolve_entity_info_cached(...).await?; + // ... 构建 query(复用 build_filtered_query_sql_ex)... + + // 注入 data_scope 条件 + if data_scope != "all" && info.entity_def.data_scope == Some(true) { + let scope_condition = DynamicTableManager::build_data_scope_condition_with_params( + data_scope, current_user_id, "owner_id", dept_members, + next_param_idx, &info.generated_fields, + ); + conditions.push(scope_condition.0); + values.extend(scope_condition.1); + } + + // ... 执行查询 ... +} +``` + +> **注意:** 由于 `build_filtered_query_sql_ex` 返回完整 SQL,无法在事后追加条件。 +> 实际实现需要将 data_scope 条件作为额外参数传入 `build_filtered_query_sql_ex`, +> 或在 `data_service` 层先调用 `build_filtered_query_sql_ex` 得到条件部分, +> 然后拼接 data_scope 条件后再执行。推荐扩展 `build_filtered_query_sql_ex` 签名, +> 新增 `extra_conditions: Option<(String, Vec)>` 参数。 + +- [ ] **Step 2: 编译验证** + +Run: `cargo check --workspace` +Expected: 编译通过 + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-plugin/src/data_service.rs crates/erp-plugin/src/handler/data_handler.rs +git commit -m "feat(plugin): 查询注入行级数据范围条件" +``` + +--- + +### Task 3.8: CRM plugin.toml — 添加 data_scope / owner_id + +**Files:** +- Modify: `crates/erp-plugin-crm/plugin.toml` + +- [ ] **Step 1: customer 实体启用 data_scope** + +```toml +[[schema.entities]] +name = "customer" +display_name = "客户" +data_scope = true + +# 新增 owner_id 字段 +[[schema.entities.fields]] +name = "owner_id" +field_type = "uuid" +display_name = "负责人" +scope_role = "owner" +``` + +- [ ] **Step 2: 权限声明添加 data_scope_levels** + +```toml +[[permissions]] +code = "customer.list" +name = "查看客户" +description = "查看客户列表和详情" +data_scope_levels = ["self", "department", "department_tree", "all"] +``` + +对 `contact.list`, `communication.list` 等也添加 data_scope_levels。 + +- [ ] **Step 3: 验证** + +Run: `cargo test -p erp-plugin -- parse_full_manifest` +Expected: PASS + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-plugin-crm/plugin.toml +git commit -m "feat(crm): customer 实体启用 data_scope + owner_id 字段" +``` + +--- + +## Chunk 4: 前端页面能力增强 + +### Task 4.1: 后端 — PATCH 部分更新端点 + build_patch_sql + +**Files:** +- Modify: `crates/erp-plugin/src/dynamic_table.rs` (新增 build_patch_sql) +- Modify: `crates/erp-plugin/src/data_service.rs` (新增 partial_update) +- Modify: `crates/erp-plugin/src/data_dto.rs` (新增 PatchPluginDataReq) +- Modify: `crates/erp-plugin/src/handler/data_handler.rs` (新增 patch_plugin_data handler) +- Modify: `crates/erp-plugin/src/handler/plugin_handler.rs` (注册 PATCH 路由) + +- [ ] **Step 1: 写失败测试** + +```rust +#[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-000000000001").unwrap(), + Uuid::parse_str("00000000-0000-0000-0000-000000000099").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 应更新版本号"); +} +``` + +- [ ] **Step 2: 新增 PatchPluginDataReq** + +在 `data_dto.rs` 中添加: + +```rust +/// 部分更新请求 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PatchPluginDataReq { + pub data: serde_json::Value, + pub version: i32, +} +``` + +- [ ] **Step 3: 实现 build_patch_sql** + +在 `dynamic_table.rs` 中: + +```rust +/// 构建 PATCH SQL — 只更新 data 中提供的字段,未提供的保持不变 +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) { + // 使用 jsonb_set 逐层合并 + let mut set_expr = "data".to_string(); + if let Some(obj) = partial_data.as_object() { + for key in obj.keys() { + set_expr = format!( + "jsonb_set({}, '{{{}}}', $1::jsonb->'{}', true)", + set_expr, key, 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) +} +``` + +- [ ] **Step 4: data_service.rs 新增 partial_update** + +```rust +/// 部分更新(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, + cache: &moka::sync::Cache, +) -> AppResult { + let info = Self::resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?; + + let (sql, values) = DynamicTableManager::build_patch_sql( + &info.table_name, id, tenant_id, operator_id, partial_data, expected_version, + ); + + #[derive(FromQueryResult)] + struct UpdateResult { + id: Uuid, data: serde_json::Value, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + version: i32, + } + + let result = UpdateResult::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), + }) +} +``` + +- [ ] **Step 5: handler 新增 patch_plugin_data** + +```rust +#[utoipa::path( + patch, + path = "/api/v1/plugins/{plugin_id}/{entity}/{id}", + request_body = PatchPluginDataReq, + responses((status = 200, description = "部分更新成功", body = ApiResponse)), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +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, &state.entity_cache, + ).await?; + + Ok(Json(ApiResponse::ok(result))) +} +``` + +- [ ] **Step 6: 注册路由** + +在 `plugin_handler.rs` 的路由定义中,添加 `.patch("/api/v1/plugins/:plugin_id/:entity/:id", patch_plugin_data::)` + +> 具体路由注册方式取决于项目使用的 Axum 路由模式。查找现有路由注册位置,按相同模式添加。 + +- [ ] **Step 7: 编译验证** + +Run: `cargo check -p erp-plugin` +Expected: 编译通过 + +- [ ] **Step 8: 提交** + +```bash +git add crates/erp-plugin/src/dynamic_table.rs crates/erp-plugin/src/data_service.rs crates/erp-plugin/src/data_dto.rs crates/erp-plugin/src/handler/data_handler.rs crates/erp-plugin/src/handler/plugin_handler.rs +git commit -m "feat(plugin): PATCH 部分更新端点 — jsonb_set 字段合并" +``` + +--- + +### Task 4.2: 后端 — 批量操作端点 + +**Files:** +- Modify: `crates/erp-plugin/src/data_dto.rs` (BatchActionReq) +- Modify: `crates/erp-plugin/src/data_service.rs` (batch 操作) +- Modify: `crates/erp-plugin/src/handler/data_handler.rs` (batch handler) + +- [ ] **Step 1: 新增 DTO** + +```rust +/// 批量操作请求 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct BatchActionReq { + pub action: String, // "batch_delete" 或 "batch_update" + pub ids: Vec, // 记录 ID 列表(上限 100) + pub data: Option, // batch_update 时的更新数据 +} +``` + +- [ ] **Step 2: 实现 batch 操作** + +在 `data_service.rs` 中: + +```rust +/// 批量操作(单个事务) +pub async fn batch( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + operator_id: Uuid, + req: BatchActionReq, + db: &sea_orm::DatabaseConnection, + cache: &moka::sync::Cache, +) -> AppResult { + if req.ids.len() > 100 { + return Err(AppError::Validation("批量操作上限 100 条".to_string())); + } + if req.ids.is_empty() { + return Err(AppError::Validation("ids 不能为空".to_string())); + } + + let info = Self::resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).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()) + })?; + // 批量更新使用原始 SQL 绕过乐观锁(管理员操作,可接受) + // 将 partial_data 中的每个字段通过 jsonb_set 合并到现有 data + let mut set_expr = "data".to_string(); + if let Some(obj) = update_data.as_object() { + for key in obj.keys() { + set_expr = format!( + "jsonb_set({}, '{{{}}}', $1::jsonb->'{}', true)", + set_expr, key, key + ); + } + } + let placeholders: Vec = ids.iter().enumerate() + .map(|(i, _)| format!("${}", i + 2)) + .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()); + // 注意:$1 是 operator_id,$2 是 update_data,$3+ 是 ids + // 需要调整参数索引: + // $1 = update_data, $2 = operator_id, $3+ = ids + let placeholders: Vec = ids.iter().enumerate() + .map(|(i, _)| format!("${}", i + 3)) + .collect(); + let sql = format!( + "UPDATE \"{}\" SET data = {}, updated_at = NOW(), updated_by = $2, 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![tenant_id.into(), 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) +} +``` + +- [ ] **Step 3: handler 注册** + +```rust +#[utoipa::path( + post, + path = "/api/v1/plugins/{plugin_id}/{entity}/batch", + request_body = BatchActionReq, + responses((status = 200, description = "批量操作成功", body = ApiResponse)), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +pub async fn batch_plugin_data(...) -> Result>, AppError> { + // 权限检查 + 调用 PluginDataService::batch +} +``` + +- [ ] **Step 4: 编译验证 + 提交** + +```bash +git add crates/erp-plugin/src/ +git commit -m "feat(plugin): 批量操作端点 — batch_delete + batch_update" +``` + +--- + +### Task 4.3: 后端 — timeseries 聚合 API + +**Files:** +- Modify: `crates/erp-plugin/src/data_dto.rs` (TimeseriesParams) +- Modify: `crates/erp-plugin/src/dynamic_table.rs` (build_timeseries_sql) +- Modify: `crates/erp-plugin/src/data_service.rs` (timeseries 方法) +- Modify: `crates/erp-plugin/src/handler/data_handler.rs` (timeseries handler) + +- [ ] **Step 1: 新增 DTO + SQL 构建 + service + handler** + +```rust +// data_dto.rs +#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] +pub struct TimeseriesParams { + pub time_field: String, // 时间字段名 + pub time_grain: String, // "day" / "week" / "month" + pub start: Option, // ISO 日期 + pub end: Option, // ISO 日期 +} + +// dynamic_table.rs +pub fn build_timeseries_sql( + table_name: &str, + tenant_id: Uuid, + time_field: &str, + time_grain: &str, + start: Option<&str>, + end: Option<&str>, +) -> Result<(String, Vec), String> { + let clean_field = sanitize_identifier(time_field); + let grain = match time_grain { + "day" => "day", + "week" => "week", + "month" => "month", + _ => return Err(format!("不支持的 time_grain: {}", time_grain)), + }; + + let mut conditions = vec![ + format!("\"tenant_id\" = $1"), + "\"deleted_at\" IS NULL".to_string(), + ]; + let mut values: Vec = vec![tenant_id.into()]; + let mut param_idx = 2; + + if let Some(s) = start { + conditions.push(format!("(data->>'{}')::timestamp >= ${}", clean_field, param_idx)); + values.push(Value::String(Some(Box::new(s.to_string())))); + param_idx += 1; + } + if let Some(e) = end { + conditions.push(format!("(data->>'{}')::timestamp < ${}", clean_field, param_idx)); + values.push(Value::String(Some(Box::new(e.to_string())))); + param_idx += 1; + } + + let sql = format!( + "SELECT to_char(date_trunc('{}', (data->>'{}')::timestamp), 'YYYY-MM-DD') as period, COUNT(*) as count \ + FROM \"{}\" WHERE {} \ + GROUP BY date_trunc('{}', (data->>'{}')::timestamp) \ + ORDER BY period", + grain, clean_field, table_name, conditions.join(" AND "), grain, clean_field, + ); + + Ok((sql, values)) +} +``` + +- [ ] **Step 2: 编译验证 + 提交** + +```bash +git add crates/erp-plugin/src/ +git commit -m "feat(plugin): timeseries 聚合 API — date_trunc 时间序列" +``` + +--- + +### Task 4.4: 前端 — API 层扩展 + +**Files:** +- Modify: `apps/web/src/api/plugins.ts` (Schema 类型扩展) +- Modify: `apps/web/src/api/pluginData.ts` (新增 batch/timeseries/cursor API) + +- [ ] **Step 1: plugins.ts 类型扩展** + +```typescript +// PluginFieldSchema 扩展 +export interface PluginFieldSchema { + // ... 已有字段 ... + ref_entity?: string; + ref_label_field?: string; + ref_search_fields?: string[]; + cascade_from?: string; + cascade_filter?: string; +} + +// PluginPageType 新增 Kanban +// 在 tagged union 中添加: +| { + type: 'kanban'; + entity: string; + label: string; + icon?: string; + lane_field: string; + lane_order?: string[]; + card_title_field: string; + card_subtitle_field?: string; + card_fields?: string[]; + enable_drag?: boolean; + } + +// Dashboard widgets +export interface DashboardWidget { + type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart'; + entity: string; + title: string; + icon?: string; + color?: string; + dimension_field?: string; + dimension_order?: string[]; + metric?: string; +} +``` + +- [ ] **Step 2: pluginData.ts API 扩展** + +```typescript +// 批量操作 +export async function batchPluginData( + pluginId: string, entity: string, req: { action: string; ids: string[]; data?: any } +) { + const { data } = await api.post(`/api/v1/plugins/${pluginId}/${entity}/batch`, req); + return data; +} + +// 部分更新 +export async function patchPluginData( + pluginId: string, entity: string, id: string, req: { data: any; version: number } +) { + const { data } = await api.patch(`/api/v1/plugins/${pluginId}/${entity}/${id}`, req); + return data; +} + +// 时间序列 +export async function getPluginTimeseries( + pluginId: string, entity: string, params: { + time_field: string; time_grain: string; start?: string; end?: string; + } +) { + const { data } = await api.get(`/api/v1/plugins/${pluginId}/${entity}/timeseries`, { params }); + return data; +} +``` + +- [ ] **Step 3: 提交** + +```bash +git add apps/web/src/api/plugins.ts apps/web/src/api/pluginData.ts +git commit -m "feat(web): API 层扩展 — batch/patch/timeseries/kanban 类型" +``` + +--- + +### Task 4.5: 前端 — EntitySelect 关联选择器组件 + +**Files:** +- Create: `apps/web/src/components/EntitySelect.tsx` + +- [ ] **Step 1: 实现 EntitySelect 组件** + +```tsx +// apps/web/src/components/EntitySelect.tsx +import { Select, Spin } from 'antd'; +import { useState, useEffect, useCallback } from 'react'; +import { listPluginData } from '../api/pluginData'; + +interface EntitySelectProps { + pluginId: string; + entity: string; + labelField: string; + searchFields?: string[]; + value?: string; + onChange?: (value: string, label: string) => void; + cascadeFrom?: string; // 级联过滤来源字段 + cascadeFilter?: string; // 级联过滤目标字段 + cascadeValue?: string; // 来源字段的当前值 + placeholder?: string; +} + +export default function EntitySelect({ + pluginId, entity, labelField, searchFields, + value, onChange, cascadeFrom, cascadeFilter, cascadeValue, + placeholder, +}: EntitySelectProps) { + const [options, setOptions] = useState<{value: string; label: string}[]>([]); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + + const fetchData = useCallback(async (keyword?: string) => { + setLoading(true); + try { + let filter: Record | undefined; + if (cascadeFrom && cascadeFilter && cascadeValue) { + filter = { [cascadeFilter]: cascadeValue }; + } + const res = await listPluginData(pluginId, entity, { + page: 1, page_size: 20, + search: keyword, + filter: filter ? JSON.stringify(filter) : undefined, + }); + const items = (res.data?.data || []).map((item: any) => ({ + value: item.id, + label: item.data?.[labelField] || item.id, + })); + setOptions(items); + } finally { + setLoading(false); + } + }, [pluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue]); + + useEffect(() => { fetchData(); }, [fetchData]); + + return ( +