Chunk 1: JSONB 存储优化 (Generated Column + pg_trgm + Keyset + Schema 缓存) Chunk 2: 数据完整性框架 (ref_entity + 级联删除 + 字段校验 + 循环检测) Chunk 3: 行级数据权限 (data_scope + TenantContext 扩展 + fallback 收紧) Chunk 4: 前端页面能力增强 (entity_select + kanban + 批量操作 + 图表)
116 KiB
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 Column(JSON 类型不适合)
pub fn supports_generated_column(&self) -> bool {
!matches!(self, Self::Json)
}
}
- Step 4: 运行测试验证通过
Run: cargo test -p erp-plugin -- generated
Expected: 全部 PASS
- Step 5: 提交
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 Column,got: {}", sql
);
}
#[test]
fn test_filtered_query_uses_generated_column_for_filter() {
let generated_fields = vec!["status".to_string()];
let (sql, _) = DynamicTableManager::build_filtered_query_sql_ex(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20, 0,
Some(serde_json::json!({"status": "active"})),
None, None, None,
&generated_fields,
).unwrap();
assert!(
sql.contains("\"_f_status\" = $"),
"过滤应使用 Generated Column,got: {}", sql
);
}
- Step 2: 验证失败
Run: cargo test -p erp-plugin -- field_reference
Expected: 编译失败
- Step 3: 实现字段引用路由
在 DynamicTableManager 中添加:
/// 返回字段引用函数的闭包 — 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;
}
}
}
// search(searchable 字段仍在 JSONB 中,使用 pg_trgm 索引)
if let Some((fields_csv, keyword)) = search {
let escaped = keyword.replace('%', "\\%").replace('_', "\\_");
let fields: Vec<&str> = fields_csv.split(',').collect();
let search_param_idx = param_idx;
let search_conditions: Vec<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 EntityInfo和impl 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,避免每次请求重建缓存:
- 在
AppState中添加pub plugin_entity_cache: moka::sync::Cache<String, erp_plugin::state::EntityInfo>- 在
main.rs中初始化:plugin_entity_cache: Cache::builder().max_capacity(1000).time_to_idle(Duration::from_secs(300)).build()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.rs 的 aggregate 方法中,新增 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?;
// 生成下一页 cursor(W2 修复:编码排序字段值)
let next_cursor = rows.last().map(|r| {
let sort_val = if let Some(ref sb) = sort_by {
r.data.get(sb).and_then(|v| v.as_str()).unwrap_or("").to_string()
} else {
// 默认排序用 created_at 的字符串表示
r.created_at.to_rfc3339()
};
DynamicTableManager::encode_cursor(&[sort_val], &r.id)
});
let items = rows.into_iter().map(|r| PluginDataResp {
id: r.id.to_string(),
data: r.data,
created_at: Some(r.created_at),
updated_at: Some(r.updated_at),
version: Some(r.version),
}).collect();
Ok((items, total, next_cursor))
}
- Step 2: 更新 handler 调用
data_handler.rs 的 list_plugin_data 需要传入 cache 和 cursor,在后续 Chunk 中统一调整。
- Step 3: 编译验证
Run: cargo check -p erp-plugin
Expected: 编译通过
- Step 4: 提交
git add crates/erp-plugin/src/data_service.rs
git commit -m "feat(plugin): list 方法集成 Generated Column 路由 + Keyset Pagination"
Task 1.9: CRM plugin.toml — 更新声明以利用 Generated Column
Files:
-
Modify:
crates/erp-plugin-crm/plugin.toml -
Step 1: 确认当前字段声明已包含必要标记
CRM 的 plugin.toml 已正确声明了 unique, filterable, sortable, searchable 标记。create_table 方法会自动根据这些标记创建 Generated Column 和索引。无需修改 plugin.toml。
验证:重新安装 CRM 插件后,plugin_erp_crm_customer 表应自动包含 _f_code, _f_name, _f_customer_type, _f_status, _f_level 等 Generated Column。
- Step 2: 集成验证
Run: 启动后端服务 cargo run -p erp-server,调用 API 卸载+重新安装 CRM 插件
Expected: 动态表包含 Generated Column,pg_trgm 索引已创建
- Step 3: 提交(如有改动)
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(¤t_id) {
let label = field.display_name.as_deref().unwrap_or(&field.name);
return Err(AppError::Validation(format!(
"字段 '{}' 形成循环引用", label
)));
}
visited.push(current_id);
// 查询 current 的 parent_id
let query_sql = format!(
"SELECT data->>'{}' as parent FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
field_name, table_name
);
#[derive(FromQueryResult)]
struct ParentRow { parent: Option<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_idscrates/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)执行相同的修改:
-
create和update和delete:删除plugin.adminfallback -
list和get和count和aggregate:删除plugin.listfallback -
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.tsx→graphRenderer.ts+graphLayout.ts+graphInteraction.ts -
Split:
apps/web/src/pages/PluginCRUDPage.tsx→CrudTable.tsx+CrudForm.tsx+CrudDetail.tsx -
Split:
apps/web/src/pages/PluginDashboardPage.tsx→DashboardWidgets.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 插件基座升级 — 集成验证通过"