Files
erp/docs/superpowers/plans/2026-04-17-crm-plugin-base-upgrade-plan.md
iven a7cf44cd46 docs: CRM 插件基座升级实施计划 — 4 Chunk 36 Task
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 09:57:58 +08:00

116 KiB
Raw Blame History

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: 创建迁移文件

// 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 中添加:

mod m20260418_000035_pg_trgm_and_entity_columns;

migrations() vec 末尾添加:

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: 提交
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 中添加:

#[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 块:

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 ColumnJSON 类型不适合)
    pub fn supports_generated_column(&self) -> bool {
        !matches!(self, Self::Json)
    }
}
  • Step 4: 运行测试验证通过

Run: cargo test -p erp-plugin -- generated Expected: 全部 PASS

  • Step 5: 提交
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 中添加:

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

#[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 块中添加:

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 字符串):

/// 生成包含 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 生成器:

/// 创建动态表(执行 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: 提交
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: 写失败测试
#[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 Columngot: {}", 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 Columngot: {}", sql
    );
}
  • Step 2: 验证失败

Run: cargo test -p erp-plugin -- field_reference Expected: 编译失败

  • Step 3: 实现字段引用路由

DynamicTableManager 中添加:

/// 返回字段引用函数的闭包 — 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<serde_json::Value>,
    search: Option<(String, String)>,
    sort_by: Option<String>,
    sort_order: Option<String>,
    generated_fields: &[String],
) -> Result<(String, Vec<Value>), 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<Value> = 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;
            }
        }
    }

    // searchsearchable 字段仍在 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<String> = 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: 提交
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 中添加:

#[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 字段:

pub struct PluginDataListParams {
    pub page: Option<u64>,
    pub page_size: Option<u64>,
    pub cursor: Option<String>,  // 新增Base64 编码的游标keyset pagination
    pub search: Option<String>,
    pub filter: Option<String>,
    pub sort_by: Option<String>,
    pub sort_order: Option<String>,
}
  • Step 3: 实现 Keyset Pagination

DynamicTableManager 中添加:

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<String>, 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<String>,
    sort_column: Option<String>,
    sort_direction: &str,
    generated_fields: &[String],
) -> Result<(String, Vec<Value>), 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<Value> = 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 依赖:

base64 = { workspace = true }

如果 workspace 中没有 base64则添加 base64 = "0.22"[dependencies]

  • Step 4: 运行测试

Run: cargo test -p erp-plugin Expected: 全部 PASS

  • Step 5: 提交
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

// 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 EntityInfoimpl EntityInfo
  • 改为引用 crate::state::EntityInfo
  • info.fields() 改为 fields_from_schema(&info.schema_json) 辅助函数
/// 从 schema_json 解析字段列表
fn fields_from_schema(schema_json: &serde_json::Value) -> AppResult<Vec<PluginField>> {
    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] 中添加:

moka = { version = "0.12", features = ["sync"] }
  • Step 2: 修改 PluginState
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<String, EntityInfo>,
}

/// 缓存的实体信息
#[derive(Clone, Debug)]
pub struct EntityInfo {
    pub table_name: String,
    pub schema_json: serde_json::Value,
    pub generated_fields: Vec<String>,
}

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 实现:

impl FromRef<AppState> 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<String, erp_plugin::state::EntityInfo>
  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<String, crate::state::EntityInfo>,
) -> AppResult<crate::state::EntityInfo> {
    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<String> = 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: 提交
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.rsaggregate 方法中,新增 Redis 缓存层:

/// 聚合查询(带 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<serde_json::Value>,
    redis: Option<&redis::Client>,
) -> AppResult<Vec<(String, i64)>> {
    // 尝试从 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: 提交
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 方法使用新的查询构建器

/// 列表查询(支持过滤/搜索/排序/游标分页)
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<String, crate::state::EntityInfo>,
    filter: Option<serde_json::Value>,
    search: Option<String>,
    sort_by: Option<String>,
    sort_order: Option<String>,
    cursor: Option<String>,
) -> AppResult<(Vec<PluginDataResp>, u64, Option<String>)> {
    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<chrono::Utc>,
        updated_at: chrono::DateTime<chrono::Utc>,
        version: i32,
    }

    let rows = DataRow::find_by_statement(Statement::from_sql_and_values(
        sea_orm::DatabaseBackend::Postgres, sql, values,
    )).all(db).await?;

    // 生成下一页 cursorW2 修复:编码排序字段值)
    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.rslist_plugin_data 需要传入 cachecursor,在后续 Chunk 中统一调整。

  • Step 3: 编译验证

Run: cargo check -p erp-plugin Expected: 编译通过

  • Step 4: 提交
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 Columnpg_trgm 索引已创建

  • Step 3: 提交(如有改动)
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: 写失败测试

#[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 中新增三个字段:

pub struct PluginField {
    // ... 已有字段 ...
    pub ref_entity: Option<String>,           // 外键引用的实体名
    pub validation: Option<FieldValidation>,  // 字段校验规则
    pub no_cycle: Option<bool>,               // 禁止循环引用
}

/// 字段校验规则
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldValidation {
    pub pattern: Option<String>,   // 正则表达式
    pub message: Option<String>,   // 校验失败提示
}

同时更新 #[cfg(test)] 中的 default_for_field() 辅助方法:

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: 提交
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: 写失败测试

#[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
/// 级联删除策略
#[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 中添加:

pub struct PluginEntity {
    pub name: String,
    pub display_name: String,
    #[serde(default)]
    pub fields: Vec<PluginField>,
    #[serde(default)]
    pub indexes: Vec<PluginIndex>,
    #[serde(default)]
    pub relations: Vec<PluginRelation>,     // 新增
}
  • Step 3: 运行测试

Run: cargo test -p erp-plugin -- parse_entity_with_relations Expected: PASS

  • Step 4: 提交
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: 写失败测试

#[tokio::test]
async fn test_validate_ref_entity_rejects_missing_reference() {
    // 简单验证逻辑单元测试ref_entity 字段指向不存在的记录时应报错
    // 注意:此测试需要数据库连接,标记为集成测试或使用 mock
    // 此处先验证 validate_ref_entities 函数签名存在
}

由于外键校验需要数据库查询,此任务更适合集成测试。此处先实现逻辑,集成测试在手动验证阶段进行。

  • Step 2: 实现 ref_entity 校验函数

data_service.rs 中新增:

/// 校验外键引用 — 检查 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<Uuid>,
) -> 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

#[derive(FromQueryResult)]
struct ExistsCheck { exists: Option<i32> }

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 调用之后添加:

validate_ref_entities(&data, &fields, entity_name, plugin_id, tenant_id, db, true, None).await?;

update 方法中添加:

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: 提交
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 中添加:

regex = "1"
  • Step 2: 扩展 validate_data

修改 validate_data 函数,在 required 检查之后增加正则校验:

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: 写单元测试
#[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: 提交
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: 写失败测试

#[test]
fn detect_cycle_in_self_reference() {
    // 循环检测是纯逻辑A.parent_id = B, B.parent_id = A → 检测到循环
    // 需要数据库查询链,此处测试 detect_cycle 函数签名
}

循环检测需要数据库交互,此处直接实现,手动集成测试验证。

  • Step 2: 实现循环检测

data_service.rs 中新增:

/// 循环引用检测 — 用于 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(&current_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<String> }
        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 之后添加:

// 循环引用检测
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: 提交
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 方法:

/// 删除(软删除)— 支持级联策略
pub async fn delete(
    plugin_id: Uuid,
    entity_name: &str,
    id: Uuid,
    tenant_id: Uuid,
    db: &sea_orm::DatabaseConnection,
    cache: &moka::sync::Cache<String, crate::state::EntityInfo>,
    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<i32> }
                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: 提交
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 字段改为:

[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "所属客户"
ref_entity = "customer"
  • Step 2: 更新 communication 实体 — 添加 ref_entity
[[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
# 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
# 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

[[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: 提交
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: 创建迁移文件

// 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: 提交
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: 写失败测试

#[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
/// 租户上下文(中间件注入)
#[derive(Debug, Clone)]
pub struct TenantContext {
    pub tenant_id: Uuid,
    pub user_id: Uuid,
    pub roles: Vec<String>,
    pub permissions: Vec<String>,
    pub department_ids: Vec<Uuid>,  // 新增:用户所属部门 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: 提交
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

// 在构造 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: 提交
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: 写失败测试

#[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: 扩展结构体
pub struct PluginEntity {
    pub name: String,
    pub display_name: String,
    pub fields: Vec<PluginField>,
    pub indexes: Vec<PluginIndex>,
    pub relations: Vec<PluginRelation>,
    pub data_scope: Option<bool>,  // 新增:是否启用行级数据权限
}

pub struct PluginField {
    // ... 已有字段 ...
    pub scope_role: Option<String>,  // 新增:标记为数据权限的"所有者"字段
}

pub struct PluginPermission {
    pub code: String,
    pub name: String,
    pub description: String,
    pub data_scope_levels: Option<Vec<String>>,  // 新增:支持的数据范围等级
}
  • Step 3: 运行测试

Run: cargo test -p erp-plugin -- parse_entity_with_data_scope Expected: PASS

  • Step 4: 提交
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: 写失败测试

#[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 构建
/// 构建数据范围 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<Value>) {
    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<String> = dept_member_ids
                    .iter()
                    .enumerate()
                    .map(|(i, _)| format!("${}", start_param_idx + i))
                    .collect();
                let values: Vec<Value> = 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: 提交
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 改为单级检查:

当前(危险):

let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
if require_permission(&ctx, &fine_perm).is_err() {
    require_permission(&ctx, "plugin.list")?;
}

修改后:

let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
require_permission(&ctx, &fine_perm)?;

对所有 handler 方法list, create, get, update, delete, count, aggregate执行相同的修改

  • createupdatedelete:删除 plugin.admin fallback

  • listgetcountaggregate:删除 plugin.list fallback

  • Step 2: 编译验证

Run: cargo check -p erp-plugin Expected: 编译通过

  • Step 3: 提交
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 等级并查询部门用户:

/// 获取当前用户对指定权限的 data_scope 等级
async fn get_data_scope(
    ctx: &TenantContext,
    permission_code: &str,
    db: &DatabaseConnection,
) -> AppResult<String> {
    #[derive(FromQueryResult)]
    struct ScopeResult { data_scope: Option<String> }

    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<Vec<Uuid>> {
    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::<Vec<_>>()
            .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::<Vec<_>>()
        .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 中调用:

// 获取 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 条件:

pub async fn list_with_scope(
    /* ... 原有参数 ... */
    data_scope: &str,
    current_user_id: &Uuid,
    dept_members: &[Uuid],
) -> AppResult<(Vec<PluginDataResp>, u64, Option<String>)> {
    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<Value>)> 参数。

  • Step 2: 编译验证

Run: cargo check --workspace Expected: 编译通过

  • Step 4: 提交
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

[[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
[[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: 提交
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: 写失败测试

#[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 中添加:

/// 部分更新请求
#[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 中:

/// 构建 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<Value>) {
    // 使用 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
/// 部分更新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<String, crate::state::EntityInfo>,
) -> AppResult<PluginDataResp> {
    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<chrono::Utc>,
        updated_at: chrono::DateTime<chrono::Utc>,
        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
#[utoipa::path(
    patch,
    path = "/api/v1/plugins/{plugin_id}/{entity}/{id}",
    request_body = PatchPluginDataReq,
    responses((status = 200, description = "部分更新成功", body = ApiResponse<PluginDataResp>)),
    security(("bearer_auth" = [])),
    tag = "插件数据"
)]
pub async fn patch_plugin_data<S>(
    State(state): State<PluginState>,
    Extension(ctx): Extension<TenantContext>,
    Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>,
    Json(req): Json<PatchPluginDataReq>,
) -> Result<Json<ApiResponse<PluginDataResp>>, AppError>
where
    PluginState: FromRef<S>,
    S: Clone + Send + Sync + 'static,
{
    let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
    let 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::<PluginState>)

具体路由注册方式取决于项目使用的 Axum 路由模式。查找现有路由注册位置,按相同模式添加。

  • Step 7: 编译验证

Run: cargo check -p erp-plugin Expected: 编译通过

  • Step 8: 提交
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

/// 批量操作请求
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct BatchActionReq {
    pub action: String,                      // "batch_delete" 或 "batch_update"
    pub ids: Vec<String>,                    // 记录 ID 列表(上限 100
    pub data: Option<serde_json::Value>,     // batch_update 时的更新数据
}
  • Step 2: 实现 batch 操作

data_service.rs 中:

/// 批量操作(单个事务)
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<String, crate::state::EntityInfo>,
) -> AppResult<u64> {
    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<Uuid> = req.ids.iter()
        .map(|s| Uuid::parse_str(s))
        .collect::<Result<Vec<_>, _>>()
        .map_err(|_| AppError::Validation("ids 中包含无效的 UUID".to_string()))?;

    let affected = match req.action.as_str() {
        "batch_delete" => {
            let placeholders: Vec<String> = 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<String> = 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<String> = 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 注册
#[utoipa::path(
    post,
    path = "/api/v1/plugins/{plugin_id}/{entity}/batch",
    request_body = BatchActionReq,
    responses((status = 200, description = "批量操作成功", body = ApiResponse<u64>)),
    security(("bearer_auth" = [])),
    tag = "插件数据"
)]
pub async fn batch_plugin_data<S>(...) -> Result<Json<ApiResponse<u64>>, AppError> {
    // 权限检查 + 调用 PluginDataService::batch
}
  • Step 4: 编译验证 + 提交
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

// 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<String>,  // ISO 日期
    pub end: Option<String>,    // 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<Value>), 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<Value> = 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: 编译验证 + 提交
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 类型扩展

// 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 扩展
// 批量操作
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: 提交
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 组件

// 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<string, string> | 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 (
    <Select
      showSearch
      value={value}
      placeholder={placeholder || '请选择'}
      loading={loading}
      options={options}
      onSearch={(v) => { setSearch(v); fetchData(v); }}
      onChange={(v) => {
        const opt = options.find(o => o.value === v);
        onChange?.(v, opt?.label || '');
      }}
      filterOption={false}
      notFoundContent={loading ? <Spin size="small" /> : '无数据'}
      allowClear
    />
  );
}
  • Step 2: 集成到 PluginCRUDPage 的表单渲染

PluginCRUDPage.tsx 的表单字段渲染中,当 ui_widget === 'entity_select' 时渲染 EntitySelect

case 'entity_select':
  return (
    <EntitySelect
      pluginId={pluginId}
      entity={field.ref_entity!}
      labelField={field.ref_label_field || 'name'}
      searchFields={field.ref_search_fields}
      value={formValues[field.name]}
      onChange={(v) => form.setFieldValue(field.name, v)}
      cascadeFrom={field.cascade_from}
      cascadeFilter={field.cascade_filter}
      cascadeValue={field.cascade_from ? formValues[field.cascade_from] : undefined}
      placeholder={field.display_name}
    />
  );
  • Step 3: 提交
git add apps/web/src/components/EntitySelect.tsx apps/web/src/pages/PluginCRUDPage.tsx
git commit -m "feat(web): EntitySelect 关联选择器 — 远程搜索 + 级联过滤"

Task 4.6: 前端 — visible_when 表达式增强

Files:

  • Create: apps/web/src/utils/exprEvaluator.ts

  • Modify: apps/web/src/pages/PluginCRUDPage.tsx (替换 visible_when 解析)

  • Step 1: 实现表达式解析器

// apps/web/src/utils/exprEvaluator.ts

interface ExprNode {
  type: 'eq' | 'and' | 'or' | 'not';
  field?: string;
  value?: string;
  left?: ExprNode;
  right?: ExprNode;
  operand?: ExprNode;
}

function parseAtom(tokens: string[]): ExprNode | null {
  const token = tokens.shift();
  if (!token) return null;
  if (token === '(') {
    const expr = parseOr(tokens);
    if (tokens[0] === ')') tokens.shift();
    return expr;
  }
  if (token === 'NOT') {
    const operand = parseAtom(tokens);
    return { type: 'not', operand: operand || undefined };
  }
  // field == 'value'
  const field = token;
  const op = tokens.shift();
  if (op !== '==' && op !== '!=') return null;
  const value = tokens.shift()?.replace(/^'(.*)'$/, '$1') || '';
  return { type: 'eq', field, value };
}

function parseAnd(tokens: string[]): ExprNode | null {
  let left = parseAtom(tokens);
  while (tokens[0] === 'AND') {
    tokens.shift();
    const right = parseAtom(tokens);
    if (left && right) left = { type: 'and', left, right };
  }
  return left;
}

function parseOr(tokens: string[]): ExprNode | null {
  let left = parseAnd(tokens);
  while (tokens[0] === 'OR') {
    tokens.shift();
    const right = parseAnd(tokens);
    if (left && right) left = { type: 'or', left, right };
  }
  return left;
}

function tokenize(input: string): string[] {
  const tokens: string[] = [];
  let i = 0;
  while (i < input.length) {
    if (input[i] === ' ') { i++; continue; }
    if (input[i] === '(' || input[i] === ')') { tokens.push(input[i]); i++; continue; }
    if (input[i] === "'") {
      let j = i + 1;
      while (j < input.length && input[j] !== "'") j++;
      tokens.push(input.substring(i, j + 1));
      i = j + 1;
      continue;
    }
    if (input[i] === '=' && input[i + 1] === '=') { tokens.push('=='); i += 2; continue; }
    if (input[i] === '!' && input[i + 1] === '=') { tokens.push('!='); i += 2; continue; }
    let j = i;
    while (j < input.length && !' ()\'='.includes(input[j]) &&
           !(input[j] === '=' && input[j + 1] === '=') &&
               !(input[j] === '!' && input[j + 1] === '=')) j++;
    tokens.push(input.substring(i, j));
    i = j;
  }
  return tokens;
}

export function parseExpr(input: string): ExprNode | null {
  const tokens = tokenize(input);
  return parseOr(tokens);
}

export function evaluateExpr(node: ExprNode, values: Record<string, unknown>): boolean {
  switch (node.type) {
    case 'eq':
      return String(values[node.field!] ?? '') === node.value;
    case 'and':
      return evaluateExpr(node.left!, values) && evaluateExpr(node.right!, values);
    case 'or':
      return evaluateExpr(node.left!, values) || evaluateExpr(node.right!, values);
    case 'not':
      return !evaluateExpr(node.operand!, values);
    default:
      return false;
  }
}

export function evaluateVisibleWhen(expr: string | undefined, values: Record<string, unknown>): boolean {
  if (!expr) return true;
  const ast = parseExpr(expr);
  return ast ? evaluateExpr(ast, values) : true;
}
  • Step 2: 替换 PluginCRUDPage 中的 visible_when 解析

将现有正则 /^(\w+)\s*==\s*'([^']*)'$/ 替换为 evaluateVisibleWhen(field.visible_when, formValues)

  • Step 3: 提交
git add apps/web/src/utils/exprEvaluator.ts apps/web/src/pages/PluginCRUDPage.tsx
git commit -m "feat(web): visible_when 增强 — 支持 AND/OR/NOT/括号 表达式"

Task 4.7: 前端 — PluginKanbanPage 看板页面

Files:

  • Create: apps/web/src/pages/PluginKanbanPage.tsx

  • Modify: apps/web/src/App.tsx (路由注册)

  • Step 1: 安装 dnd-kit

Run: cd apps/web && pnpm add @dnd-kit/core @dnd-kit/sortable

  • Step 2: 实现 Kanban 组件
// apps/web/src/pages/PluginKanbanPage.tsx
import { useState, useEffect } from 'react';
import { Card, Spin, Typography, Tag, message } from 'antd';
import { DndContext, DragEndEvent, DragOverlay } from '@dnd-kit/core';
import { listPluginData, patchPluginData, countPluginData } from '../api/pluginData';

interface KanbanPageProps {
  pluginId: string;
  page: any; // PluginPageType.Kanban
}

export default function PluginKanbanPage({ pluginId, page }: KanbanPageProps) {
  const { entity, lane_field, lane_order = [], card_title_field, card_subtitle_field, card_fields, enable_drag } = page;
  const [lanes, setLanes] = useState<Record<string, any[]>>({});
  const [loading, setLoading] = useState(true);

  const fetchData = async () => {
    setLoading(true);
    try {
      const allData: Record<string, any[]> = {};
      for (const lane of lane_order) {
        const res = await listPluginData(pluginId, entity, {
          page: 1, page_size: 100,
          filter: JSON.stringify({ [lane_field]: lane }),
        });
        allData[lane] = res.data?.data || [];
      }
      setLanes(allData);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => { fetchData(); }, [pluginId, entity]);

  const handleDragEnd = async (event: DragEndEvent) => {
    if (!enable_drag) return;
    const { active, over } = event;
    if (!over) return;

    const recordId = active.id as string;
    const newLane = over.data.current?.lane as string;
    if (!newLane) return;

    try {
      await patchPluginData(pluginId, entity, recordId, {
        data: { [lane_field]: newLane }, version: 0,
      });
      message.success('移动成功');
      fetchData();
    } catch {
      message.error('移动失败');
    }
  };

  if (loading) return <Spin />;

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <div style={{ display: 'flex', gap: 16, overflowX: 'auto', padding: 16 }}>
        {lane_order.map(lane => (
          <div key={lane} style={{ minWidth: 280, flex: 1 }}>
            <Typography.Title level={5} style={{ marginBottom: 8 }}>
              {lane} <Tag>{lanes[lane]?.length || 0}</Tag>
            </Typography.Title>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
              {(lanes[lane] || []).map(item => (
                <Card key={item.id} size="small" id={item.id}
                  data-lane={lane} style={{ cursor: enable_drag ? 'grab' : 'default' }}>
                  <Typography.Text strong>{item.data?.[card_title_field]}</Typography.Text>
                  {card_subtitle_field && (
                    <div><Typography.Text type="secondary">{item.data?.[card_subtitle_field]}</Typography.Text></div>
                  )}
                  {card_fields?.map(f => (
                    item.data?.[f] ? <Tag key={f}>{item.data[f]}</Tag> : null
                  ))}
                </Card>
              ))}
            </div>
          </div>
        ))}
      </div>
    </DndContext>
  );
}
  • Step 3: App.tsx 路由注册

在插件路由中添加 kanban 页面类型的匹配:

case 'kanban':
  return <PluginKanbanPage pluginId={pluginId} page={pageConfig} />;
  • Step 4: 提交
git add apps/web/src/pages/PluginKanbanPage.tsx apps/web/src/App.tsx apps/web/package.json
git commit -m "feat(web): Kanban 看板页面 — dnd-kit 拖拽 + 跨列移动"

Task 4.8: 前端 — CRUD 批量操作

Files:

  • Modify: apps/web/src/pages/PluginCRUDPage.tsx

  • Step 1: 添加 rowSelection 和批量操作栏

在 CRUD 页面的 Table 组件中添加:

const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);

// Table rowSelection
<Table
  rowSelection={{
    selectedRowKeys,
    onChange: (keys) => setSelectedRowKeys(keys as string[]),
  }}
  // ... 已有 props
/>

// 批量操作栏
{selectedRowKeys.length > 0 && (
  <div style={{ padding: '12px 0', display: 'flex', gap: 8, alignItems: 'center' }}>
    <span>已选择 {selectedRowKeys.length} </span>
    <Button danger onClick={() => handleBatchDelete(selectedRowKeys)}>批量删除</Button>
    {/* 根据 page.batch_actions 渲染更多按钮 */}
  </div>
)}
  • Step 2: 实现批量操作函数
const handleBatchDelete = async (ids: string[]) => {
  await batchPluginData(pluginId, entity, { action: 'batch_delete', ids });
  message.success('批量删除成功');
  setSelectedRowKeys([]);
  fetchData();
};
  • Step 3: 提交
git add apps/web/src/pages/PluginCRUDPage.tsx
git commit -m "feat(web): CRUD 页面批量操作 — 多选 + 批量删除"

Task 4.9: 前端 — Dashboard 图表增强

Files:

  • Modify: apps/web/src/pages/PluginDashboardPage.tsx

  • 新增依赖: @ant-design/charts

  • Step 1: 安装图表库

Run: cd apps/web && pnpm add @ant-design/charts

  • Step 2: Dashboard 渲染 widgets

PluginDashboardPage.tsx 中,根据 widgets 声明渲染不同类型的图表:

import { Column, Pie, Funnel, Line } from '@ant-design/charts';

// 根据 widget.type 渲染
const renderWidget = (widget: DashboardWidget) => {
  switch (widget.type) {
    case 'stat_card':
      return <StatCardWidget key={widget.title} widget={widget} />;
    case 'bar_chart':
      return <BarChartWidget key={widget.title} widget={widget} />;
    case 'pie_chart':
      return <PieChartWidget key={widget.title} widget={widget} />;
    case 'funnel_chart':
      return <FunnelChartWidget key={widget.title} widget={widget} />;
    case 'line_chart':
      return <LineChartWidget key={widget.title} widget={widget} />;
  }
};

每个 widget 子组件从 aggregatePluginData API 获取数据并渲染对应图表。

  • Step 3: 并行数据加载

将现有的串行聚合查询改为 Promise.all 并行加载:

const [counts, aggregates] = await Promise.all([
  Promise.all(entities.map(e => countPluginData(pluginId, e))),
  Promise.all(widgets.map(w => aggregatePluginData(pluginId, w.entity, w.dimension_field!))),
]);
  • Step 4: 提交
git add apps/web/src/pages/PluginDashboardPage.tsx apps/web/package.json
git commit -m "feat(web): Dashboard 图表增强 — bar/pie/funnel/line + 并行加载"

Task 4.10: 前端 — 文件拆分重构

Files:

  • Split: apps/web/src/pages/PluginGraphPage.tsxgraphRenderer.ts + graphLayout.ts + graphInteraction.ts

  • Split: apps/web/src/pages/PluginCRUDPage.tsxCrudTable.tsx + CrudForm.tsx + CrudDetail.tsx

  • Split: apps/web/src/pages/PluginDashboardPage.tsxDashboardWidgets.tsx + dashboardTypes.ts

  • Step 1: PluginGraphPage 拆分

将 1081 行拆分为:

  • graphRenderer.ts (~300 行) — Canvas 绘制逻辑

  • graphLayout.ts (~200 行) — 力导向布局算法

  • graphInteraction.ts (~300 行) — 拖拽/缩放/点击交互

  • PluginGraphPage.tsx (~200 行) — 组件壳,组装以上模块

  • Step 2: PluginCRUDPage 拆分

将 617 行拆分为:

  • CrudTable.tsx (~250 行) — 表格展示 + 批量操作

  • CrudForm.tsx (~200 行) — 创建/编辑表单 + EntitySelect

  • CrudDetail.tsx (~150 行) — 详情 Drawer

  • PluginCRUDPage.tsx (~100 行) — 页面壳,组装子组件

  • Step 3: PluginDashboardPage 拆分

将 647 行拆分为:

  • dashboardTypes.ts (~50 行) — 类型定义

  • DashboardWidgets.tsx (~300 行) — 各类型 widget 组件

  • PluginDashboardPage.tsx (~150 行) — 页面布局 + 数据加载

  • Step 4: 验证无回归

Run: pnpm dev 并手动验证每个页面功能不变。

  • Step 5: 提交
git add apps/web/src/pages/ apps/web/src/utils/
git commit -m "refactor(web): 拆分大文件 — Graph/CRUD/Dashboard 每个文件 < 400 行"

Task 4.11: CRM plugin.toml — 前端页面声明扩展

Files:

  • Modify: crates/erp-plugin-crm/plugin.toml

  • Step 1: contact/customer_id 添加 entity_select

[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "所属客户"
ui_widget = "entity_select"
ref_entity = "customer"
ref_label_field = "name"
ref_search_fields = ["name", "code"]
  • Step 2: communication/contact_id 添加级联 entity_select
[[schema.entities.fields]]
name = "contact_id"
field_type = "uuid"
display_name = "关联联系人"
ui_widget = "entity_select"
ref_entity = "contact"
ref_label_field = "name"
ref_search_fields = ["name"]
cascade_from = "customer_id"
cascade_filter = "customer_id"
  • Step 3: 添加 Kanban 页面
[[ui.pages]]
type = "kanban"
entity = "customer"
label = "销售漏斗"
icon = "swap"
lane_field = "level"
lane_order = ["potential", "normal", "vip", "svip"]
card_title_field = "name"
card_subtitle_field = "code"
card_fields = ["region", "status"]
enable_drag = true
  • Step 4: 验证

Run: cargo test -p erp-plugin -- parse_full_manifest Expected: PASS

  • Step 5: 提交
git add crates/erp-plugin-crm/plugin.toml
git commit -m "feat(crm): entity_select + kanban + 级联过滤声明"

Task 4.12: 集成验证

  • Step 1: 后端全量编译 + 测试

Run: cargo check --workspace && cargo test --workspace Expected: 全部通过

  • Step 2: 前端编译

Run: cd apps/web && pnpm build Expected: 构建成功

  • Step 3: 启动服务 + 手动验证

Run: cargo run -p erp-server

  • 卸载 CRM 插件 → 重新安装 → 验证动态表包含 Generated Column

  • 创建客户 → 验证 ref_entity 校验

  • 删除客户 → 验证级联删除

  • 验证 Kanban 页面渲染

  • 验证 EntitySelect 组件工作正常

  • Step 4: 最终提交

git add -A
git commit -m "chore: CRM 插件基座升级 — 集成验证通过"