# 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 (