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

3797 lines
116 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CRM 插件基座升级实施计划
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 升级 CRM 插件基座 — JSONB 存储优化、数据完整性框架、行级数据权限、前端页面能力增强
**Architecture:** 基座优先策略,所有改进沉淀为插件平台通用能力。后端 Rust (Axum + SeaORM + PostgreSQL),前端 React + Ant Design。依赖顺序Generated Column → ref_entity → data_scope → 前端增强。
**Tech Stack:** Rust/Axum, PostgreSQL 16 (Generated Column + pg_trgm), Redis 7 (缓存), React 19 + Ant Design 6 + @ant-design/charts, @dnd-kit
**设计规格:** `docs/superpowers/specs/2026-04-17-crm-plugin-base-upgrade-design.md`
---
## 依赖关系图
```
Chunk 1 (JSONB 存储) ──┬──→ Chunk 2 (数据完整性)
├──→ Chunk 3 (行级权限)
└──→ Chunk 4 (前端增强)
│ │ │
│ pg_trgm 迁移 │ manifest 扩展 │ TenantContext 扩展
│ Generated Column │ ref_entity/relations │ data_scope
│ Keyset Pagination │ validation/no_cycle │ permission fallback
│ Schema 缓存 │ cascade delete │
│ 聚合 Redis 缓存 │ │
```
Chunk 1 是其他所有 Chunk 的基础。Chunk 2 和 Chunk 3 互相独立可并行。Chunk 4 依赖 Chunk 1-3 的后端 API。
---
## Chunk 1: JSONB 存储优化
### Task 1.1: 数据库迁移 — pg_trgm 扩展 + plugin_entity_columns 元数据表
**Files:**
- Create: `crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs`
- Modify: `crates/erp-server/migration/src/lib.rs`
- [ ] **Step 1: 创建迁移文件**
```rust
// crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 启用 pg_trgm 扩展(加速 ILIKE '%keyword%' 搜索)
manager
.get_connection()
.execute_unprepared("CREATE EXTENSION IF NOT EXISTS pg_trgm")
.await?;
// 插件实体列元数据表 — 记录哪些字段被提取为 Generated Column
manager
.create_table(
Table::create()
.table(Alias::new("plugin_entity_columns"))
.if_not_exists()
.col(
ColumnDef::new(Alias::new("id"))
.uuid()
.not_null()
.default(Expr::cust("gen_random_uuid()"))
.primary_key(),
)
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("plugin_entity_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("field_name")).string().not_null())
.col(ColumnDef::new(Alias::new("column_name")).string().not_null())
.col(ColumnDef::new(Alias::new("sql_type")).string().not_null())
.col(
ColumnDef::new(Alias::new("is_generated"))
.boolean()
.not_null()
.default(true),
)
.col(
ColumnDef::new(Alias::new("created_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::cust("NOW()")),
)
.to_owned(),
)
.await?;
// plugin_entity_id 外键
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_plugin_entity_columns_entity")
.from(
Alias::new("plugin_entity_columns"),
Alias::new("plugin_entity_id"),
)
.to(Alias::new("plugin_entities"), Alias::new("id"))
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(
Table::drop()
.table(Alias::new("plugin_entity_columns"))
.to_owned(),
)
.await?;
// pg_trgm 不卸载(其他功能可能依赖)
Ok(())
}
}
```
- [ ] **Step 2: 注册迁移**
`crates/erp-server/migration/src/lib.rs` 中添加:
```rust
mod m20260418_000035_pg_trgm_and_entity_columns;
```
`migrations()` vec 末尾添加:
```rust
Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration),
```
- [ ] **Step 3: 验证迁移**
Run: `cargo check -p erp-server`
Expected: 编译通过
Run: `cargo test -p erp-server -- --test-threads=1`(或启动服务让迁移自动执行)
Expected: 迁移成功
- [ ] **Step 4: 提交**
```bash
git add crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs crates/erp-server/migration/src/lib.rs
git commit -m "feat(db): 添加 pg_trgm 扩展 + plugin_entity_columns 元数据表"
```
---
### Task 1.2: manifest.rs — Generated Column 类型映射
**Files:**
- Modify: `crates/erp-plugin/src/manifest.rs:49-84` (PluginField, PluginFieldType)
- [ ] **Step 1: 写失败测试**
`crates/erp-plugin/src/manifest.rs``#[cfg(test)] mod tests` 中添加:
```rust
#[test]
fn field_type_to_sql_mapping() {
use crate::manifest::PluginFieldType;
assert_eq!(PluginFieldType::String.generated_sql_type(), "TEXT");
assert_eq!(PluginFieldType::Integer.generated_sql_type(), "INTEGER");
assert_eq!(PluginFieldType::Float.generated_sql_type(), "DOUBLE PRECISION");
assert_eq!(PluginFieldType::Decimal.generated_sql_type(), "NUMERIC");
assert_eq!(PluginFieldType::Boolean.generated_sql_type(), "BOOLEAN");
assert_eq!(PluginFieldType::Date.generated_sql_type(), "DATE");
assert_eq!(PluginFieldType::DateTime.generated_sql_type(), "TIMESTAMPTZ");
assert_eq!(PluginFieldType::Uuid.generated_sql_type(), "UUID");
assert_eq!(PluginFieldType::Json.generated_sql_type(), "TEXT"); // JSON 不适合 Generated Column
}
#[test]
fn field_type_generated_expression() {
use crate::manifest::PluginFieldType;
assert_eq!(PluginFieldType::String.generated_expr("name"), "data->>'name'");
assert_eq!(PluginFieldType::Integer.generated_expr("age"), "(data->>'age')::INTEGER");
assert_eq!(PluginFieldType::Uuid.generated_expr("ref_id"), "(data->>'ref_id')::UUID");
}
```
- [ ] **Step 2: 验证测试失败**
Run: `cargo test -p erp-plugin -- generated_sql_type`
Expected: 编译失败(方法不存在)
- [ ] **Step 3: 实现 PluginFieldType 扩展**
`PluginFieldType` enum 定义之后添加 impl 块:
```rust
impl PluginFieldType {
/// Generated Column 的 SQL 类型
pub fn generated_sql_type(&self) -> &'static str {
match self {
Self::String | Self::Json => "TEXT",
Self::Integer => "INTEGER",
Self::Float => "DOUBLE PRECISION",
Self::Decimal => "NUMERIC",
Self::Boolean => "BOOLEAN",
Self::Date => "DATE",
Self::DateTime => "TIMESTAMPTZ",
Self::Uuid => "UUID",
}
}
/// Generated Column 的表达式 — TEXT 类型直接取值,其他类型做类型转换
pub fn generated_expr(&self, field_name: &str) -> String {
match self {
Self::String | Self::Json => format!("data->>'{}'", field_name),
_ => format!("(data->>'{}')::{}", field_name, self.generated_sql_type()),
}
}
/// 该类型是否适合生成 Generated 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: 提交**
```bash
git add crates/erp-plugin/src/manifest.rs
git commit -m "feat(plugin): PluginFieldType 添加 Generated Column 类型映射"
```
---
### Task 1.3: dynamic_table.rs — Generated Column DDL + 索引策略重写
**Files:**
- Modify: `crates/erp-plugin/src/dynamic_table.rs:27-118` (create_table 方法)
- [ ] **Step 1: 写失败测试**
`dynamic_table.rs``#[cfg(test)] mod tests` 中添加:
```rust
#[test]
fn test_build_create_table_sql_with_generated_columns() {
use crate::manifest::{PluginEntity, PluginField, PluginFieldType};
let entity = PluginEntity {
name: "customer".to_string(),
display_name: "客户".to_string(),
fields: vec![
PluginField {
name: "code".to_string(),
field_type: PluginFieldType::String,
required: true, unique: true,
display_name: Some("编码".to_string()),
searchable: Some(true),
..Default::default_for_field()
},
PluginField {
name: "level".to_string(),
field_type: PluginFieldType::String,
filterable: true,
display_name: Some("等级".to_string()),
..Default::default_for_field()
},
PluginField {
name: "sort_order".to_string(),
field_type: PluginFieldType::Integer,
sortable: true,
display_name: Some("排序".to_string()),
..Default::default_for_field()
},
],
indexes: vec![],
};
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
// 验证 Generated Column 存在
assert!(sql.contains("_f_code"), "应包含 _f_code Generated Column");
assert!(sql.contains("_f_level"), "应包含 _f_level Generated Column");
assert!(sql.contains("_f_sort_order"), "应包含 _f_sort_order Generated Column");
assert!(sql.contains("GENERATED ALWAYS AS"), "应包含 GENERATED ALWAYS AS");
// Integer 类型需要类型转换
assert!(sql.contains("::INTEGER"), "Integer 字段应有类型转换");
}
#[test]
fn test_build_create_table_sql_pg_trgm_search_index() {
use crate::manifest::{PluginEntity, PluginField, PluginFieldType};
let entity = PluginEntity {
name: "customer".to_string(),
display_name: "客户".to_string(),
fields: vec![
PluginField {
name: "name".to_string(),
field_type: PluginFieldType::String,
searchable: Some(true),
display_name: Some("名称".to_string()),
..Default::default_for_field()
},
],
indexes: vec![],
};
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
assert!(sql.contains("gin_trgm_ops"), "searchable 字段应使用 pg_trgm GIN 索引");
}
```
> **注意:** `PluginField` 当前没有 `Default` 实现。为每个新增字段维护 `default_for_field()` 容易遗漏。
> **推荐做法:** 直接为 `PluginField` 派生 `#[derive(Default)]`,并让 `PluginFieldType` 的 Default 为 `String`
>
> ```rust
> #[derive(Debug, Clone, Serialize, Deserialize, Default)]
> #[serde(rename_all = "snake_case")]
> pub enum PluginFieldType {
> #[default]
> String,
> Integer, Float, Decimal, Boolean, Date, DateTime, Json, Uuid,
> }
> ```
>
> 这样 `PluginField::default()` 自动包含所有字段的默认值,新增字段无需手动同步。如果派生 Default 不可行(因为 serde 需求),则使用以下手动辅助函数:
>
> ⚠️ **重要:** 每个扩展 `PluginField` 的 Task 都必须同步更新 `default_for_field()`,否则后续 Task 的测试会编译失败。
`PluginField` struct 的 `impl PluginField` 块中添加:
```rust
impl PluginField {
/// 用于测试的默认值构造器
/// ⚠️ 每次新增 PluginField 字段后必须同步更新此方法
#[cfg(test)]
pub fn default_for_field() -> Self {
Self {
name: String::new(),
field_type: PluginFieldType::String,
required: false,
unique: false,
default: None,
display_name: None,
ui_widget: None,
options: None,
searchable: None,
filterable: None,
sortable: None,
visible_when: None,
ref_entity: None, // Task 2.1 新增
validation: None, // Task 2.1 新增
no_cycle: None, // Task 2.1 新增
scope_role: None, // Task 3.4 新增
}
}
}
```
- [ ] **Step 2: 验证测试失败**
Run: `cargo test -p erp-plugin -- test_build_create_table`
Expected: 编译失败(`build_create_table_sql` 方法不存在,`default_for_field` 方法不存在)
- [ ] **Step 3: 重写 create_table 为 build_create_table_sql + 新的 create_table**
`DynamicTableManager` 中新增纯函数方法(不执行 SQL只生成 DDL 字符串):
```rust
/// 生成包含 Generated Column 的建表 DDL
pub fn build_create_table_sql(plugin_id: &str, entity: &PluginEntity) -> String {
let table_name = Self::table_name(plugin_id, &entity.name);
let t = sanitize_identifier(&table_name);
let mut gen_cols = Vec::new();
let mut indexes = Vec::new();
for field in &entity.fields {
if !field.field_type.supports_generated_column() {
continue;
}
// 提取规则unique / (required && (sortable||filterable)) / sortable / filterable
let should_extract = field.unique
|| field.sortable == Some(true)
|| field.filterable == Some(true)
|| (field.required && (field.sortable == Some(true) || field.filterable == Some(true)));
if !should_extract {
continue;
}
let col_name = format!("_f_{}", sanitize_identifier(&field.name));
let sql_type = field.field_type.generated_sql_type();
let expr = field.field_type.generated_expr(&sanitize_identifier(&field.name));
gen_cols.push(format!(
" \"{}\" {} GENERATED ALWAYS AS ({}) STORED",
col_name, sql_type, expr
));
// 索引策略
let col_idx = format!("{}_{}", sanitize_identifier(&table_name), col_name);
if field.unique {
indexes.push(format!(
"CREATE UNIQUE INDEX IF NOT EXISTS \"idx_{}_uniq\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL",
col_idx, table_name, col_name
));
} else {
indexes.push(format!(
"CREATE INDEX IF NOT EXISTS \"idx_{}\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL",
col_idx, table_name, col_name
));
}
}
// pg_trgm 索引searchable 字段)
for field in &entity.fields {
if field.searchable == Some(true) && matches!(field.field_type, PluginFieldType::String) {
let sf = sanitize_identifier(&field.name);
indexes.push(format!(
"CREATE INDEX IF NOT EXISTS \"idx_{}_{}_trgm\" ON \"{}\" USING GIN ((data->>'{}') gin_trgm_ops) WHERE deleted_at IS NULL",
sanitize_identifier(&table_name), sf, table_name, sf
));
}
}
// 覆盖索引tenant_id + created_at加速默认排序查询
indexes.push(format!(
"CREATE INDEX IF NOT EXISTS \"idx_{}_tenant_cover\" ON \"{}\" (tenant_id, created_at DESC) INCLUDE (id, data, updated_at, version) WHERE deleted_at IS NULL",
sanitize_identifier(&table_name), table_name
));
let gen_cols_sql = if gen_cols.is_empty() {
String::new()
} else {
format!(",\n{}", gen_cols.join(",\n"))
};
format!(
"CREATE TABLE IF NOT EXISTS \"{}\" (\
\"id\" UUID PRIMARY KEY DEFAULT gen_random_uuid(), \
\"tenant_id\" UUID NOT NULL, \
\"data\" JSONB NOT NULL DEFAULT '{{}}'{gen_cols}, \
\"created_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
\"updated_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
\"created_by\" UUID, \
\"updated_by\" UUID, \
\"deleted_at\" TIMESTAMPTZ, \
\"version\" INT NOT NULL DEFAULT 1);\n\
{}",
table_name,
indexes.join(";\n"),
gen_cols = gen_cols_sql,
)
}
```
然后重构现有 `create_table` 方法调用新的 DDL 生成器:
```rust
/// 创建动态表(执行 DDL
pub async fn create_table(
db: &DatabaseConnection,
plugin_id: &str,
entity: &PluginEntity,
) -> PluginResult<()> {
let ddl = Self::build_create_table_sql(plugin_id, entity);
// 分号分割,逐条执行
for sql in ddl.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
tracing::info!(sql = %sql, "Executing DDL");
db.execute_unprepared(sql).await.map_err(|e| {
tracing::error!(sql = %sql, error = %e, "DDL execution failed");
PluginError::DatabaseError(e.to_string())
})?;
}
tracing::info!(plugin_id = %plugin_id, entity = %entity.name, "Dynamic table created with Generated Columns");
Ok(())
}
```
> **重要:** 删除原有的手动索引创建循环(原 `create_table` 中的 for field in &entity.fields 索引创建代码),全部由 `build_create_table_sql` 生成。
- [ ] **Step 4: 运行测试**
Run: `cargo test -p erp-plugin -- test_build_create_table`
Expected: PASS
Run: `cargo test -p erp-plugin` (全部测试)
Expected: 原有 15 个测试 + 新增 2 个测试全部 PASS
- [ ] **Step 5: 提交**
```bash
git add crates/erp-plugin/src/dynamic_table.rs
git commit -m "feat(plugin): create_table 使用 Generated Column + pg_trgm + 覆盖索引"
```
---
### Task 1.4: dynamic_table.rs — SQL 查询路由Generated Column 优先)
**Files:**
- Modify: `crates/erp-plugin/src/dynamic_table.rs`
当 Generated Column 存在时,查询中 `data->>'field'` 应替换为 `_f_field`,利用 B-tree 索引。
- [ ] **Step 1: 写失败测试**
```rust
#[test]
fn test_field_reference_uses_generated_column() {
// 当字段存在 Generated Column 时SQL 应使用 _f_{name} 而非 data->>'{name}'
let generated_fields = vec!["code".to_string(), "status".to_string(), "level".to_string()];
let ref_fn = DynamicTableManager::field_reference_fn(&generated_fields);
assert_eq!(ref_fn("code"), "_f_code");
assert_eq!(ref_fn("status"), "_f_status");
assert_eq!(ref_fn("name"), "data->>'name'"); // 非 Generated Column
assert_eq!(ref_fn("remark"), "data->>'remark'"); // 非 Generated Column
}
#[test]
fn test_filtered_query_uses_generated_column_for_sort() {
let generated_fields = vec!["code".to_string(), "level".to_string()];
let (sql, _) = DynamicTableManager::build_filtered_query_sql_ex(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20, 0, None, None,
Some("level".to_string()),
Some("asc".to_string()),
&generated_fields,
).unwrap();
assert!(
sql.contains("ORDER BY \"_f_level\" ASC"),
"排序应使用 Generated 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` 中添加:
```rust
/// 返回字段引用函数的闭包 — Generated Column 存在时用 _f_{name},否则用 data->>'{name}'
pub fn field_reference_fn(generated_fields: &[String]) -> impl Fn(&str) -> String + '_ {
move |field_name: &str| {
let clean = sanitize_identifier(field_name);
if generated_fields.contains(&clean) {
format!("\"_f_{}\"", clean)
} else {
format!("\"data\"->>'{}'", clean)
}
}
}
/// 扩展版查询构建 — 支持 Generated Column 路由
pub fn build_filtered_query_sql_ex(
table_name: &str,
tenant_id: Uuid,
limit: u64,
offset: u64,
filter: Option<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: 提交**
```bash
git add crates/erp-plugin/src/dynamic_table.rs
git commit -m "feat(plugin): SQL 查询路由 — Generated Column 字段优先使用 _f_ 前缀列"
```
---
### Task 1.5: dynamic_table.rs — Keyset Pagination
**Files:**
- Modify: `crates/erp-plugin/src/dynamic_table.rs`
- Modify: `crates/erp-plugin/src/data_dto.rs:28-38` (PluginDataListParams)
- [ ] **Step 1: 写失败测试**
`dynamic_table.rs` tests 中添加:
```rust
#[test]
fn test_keyset_cursor_encode_decode() {
let cursor = DynamicTableManager::encode_cursor(
&["测试值".to_string()],
&Uuid::parse_str("00000000-0000-0000-0000-000000000042").unwrap(),
);
let decoded = DynamicTableManager::decode_cursor(&cursor).unwrap();
assert_eq!(decoded.0, vec!["测试值"]);
assert_eq!(decoded.1, Uuid::parse_str("00000000-0000-0000-0000-000000000042").unwrap());
}
#[test]
fn test_keyset_sql_first_page() {
let (sql, _) = DynamicTableManager::build_keyset_query_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20,
None, // 第一页无 cursor
Some("_f_name".to_string()),
"ASC",
&[],
).unwrap();
assert!(sql.contains("ORDER BY"), "应有 ORDER BY");
assert!(!sql.contains(">"), "第一页不应有 cursor 条件");
}
#[test]
fn test_keyset_sql_with_cursor() {
let cursor = DynamicTableManager::encode_cursor(
&["Alice".to_string()],
&Uuid::parse_str("00000000-0000-0000-0000-000000000100").unwrap(),
);
let (sql, values) = DynamicTableManager::build_keyset_query_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20,
Some(cursor),
Some("_f_name".to_string()),
"ASC",
&[],
).unwrap();
assert!(sql.contains("ROW("), "cursor 条件应使用 ROW 比较");
assert!(values.len() >= 4, "应有 tenant_id + cursor_val + cursor_id + limit");
}
```
- [ ] **Step 2: 在 data_dto.rs 添加 cursor 字段**
`PluginDataListParams` 中添加 `cursor` 字段:
```rust
pub struct PluginDataListParams {
pub page: Option<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` 中添加:
```rust
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
/// 编码游标JSON { "v": [values], "id": "uuid" } → Base64
pub fn encode_cursor(values: &[String], id: &Uuid) -> String {
let obj = serde_json::json!({
"v": values,
"id": id.to_string(),
});
BASE64.encode(obj.to_string())
}
/// 解码游标
pub fn decode_cursor(cursor: &str) -> Result<(Vec<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` 依赖:
```toml
base64 = { workspace = true }
```
> 如果 workspace 中没有 base64则添加 `base64 = "0.22"` 到 `[dependencies]`。
- [ ] **Step 4: 运行测试**
Run: `cargo test -p erp-plugin`
Expected: 全部 PASS
- [ ] **Step 5: 提交**
```bash
git add crates/erp-plugin/src/dynamic_table.rs crates/erp-plugin/src/data_dto.rs crates/erp-plugin/Cargo.toml
git commit -m "feat(plugin): Keyset Pagination — cursor 编解码 + 游标分页 SQL"
```
---
### Task 1.6: state.rs — Schema 缓存 (moka)
**Files:**
- Modify: `crates/erp-plugin/src/state.rs`
- Modify: `crates/erp-server/src/state.rs` (**C1 修复:更新 FromRef 实现**)
- Modify: `crates/erp-plugin/Cargo.toml`
- [ ] **Step 0: 将 `dynamic_table.rs` 中的 `sanitize_identifier` 改为 `pub(crate) fn`**
```rust
// dynamic_table.rs — 将 fn sanitize_identifier 改为 pub(crate) fn
pub(crate) fn sanitize_identifier(input: &str) -> String {
```
同时在 `data_service.rs` 中将私有 `EntityInfo` 改为使用 `state::EntityInfo`(避免类型冲突):
- 删除 `data_service.rs` 中的私有 `struct EntityInfo``impl EntityInfo`
- 改为引用 `crate::state::EntityInfo`
-`info.fields()` 改为 `fields_from_schema(&info.schema_json)` 辅助函数
```rust
/// 从 schema_json 解析字段列表
fn fields_from_schema(schema_json: &serde_json::Value) -> AppResult<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]` 中添加:
```toml
moka = { version = "0.12", features = ["sync"] }
```
- [ ] **Step 2: 修改 PluginState**
```rust
use moka::sync::Cache;
use std::time::Duration;
use sea_orm::DatabaseConnection;
use erp_core::events::EventBus;
use crate::engine::PluginEngine;
/// 插件模块共享状态
#[derive(Clone)]
pub struct PluginState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub engine: PluginEngine,
/// Schema 缓存 — key: "{plugin_id}:{entity_name}:{tenant_id}"
pub entity_cache: Cache<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 实现:**
```rust
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: 提交**
```bash
git add crates/erp-plugin/src/state.rs crates/erp-plugin/src/data_service.rs crates/erp-plugin/src/dynamic_table.rs crates/erp-plugin/Cargo.toml
git commit -m "feat(plugin): Schema 缓存 — moka LRU Cache 消除 resolve_entity_info 重复查库"
```
---
### Task 1.7: data_service.rs — 聚合 Redis 缓存
**Files:**
- Modify: `crates/erp-plugin/src/data_service.rs`
- [ ] **Step 1: 添加 Redis 缓存逻辑**
`data_service.rs``aggregate` 方法中,新增 Redis 缓存层:
```rust
/// 聚合查询(带 Redis 缓存)
pub async fn aggregate_cached(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
group_by_field: &str,
filter: Option<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: 提交**
```bash
git add crates/erp-plugin/src/data_service.rs
git commit -m "feat(plugin): 聚合查询 Redis 缓存骨架"
```
---
### Task 1.8: data_service.rs — list 方法集成 Generated Column 路由 + Keyset
**Files:**
- Modify: `crates/erp-plugin/src/data_service.rs:76-165` (list 方法)
- [ ] **Step 1: 修改 list 方法使用新的查询构建器**
```rust
/// 列表查询(支持过滤/搜索/排序/游标分页)
pub async fn list(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
page: u64,
page_size: u64,
db: &sea_orm::DatabaseConnection,
cache: &moka::sync::Cache<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.rs``list_plugin_data` 需要传入 `cache``cursor`,在后续 Chunk 中统一调整。
- [ ] **Step 3: 编译验证**
Run: `cargo check -p erp-plugin`
Expected: 编译通过
- [ ] **Step 4: 提交**
```bash
git add crates/erp-plugin/src/data_service.rs
git commit -m "feat(plugin): list 方法集成 Generated Column 路由 + Keyset Pagination"
```
---
### Task 1.9: CRM plugin.toml — 更新声明以利用 Generated Column
**Files:**
- Modify: `crates/erp-plugin-crm/plugin.toml`
- [ ] **Step 1: 确认当前字段声明已包含必要标记**
CRM 的 `plugin.toml` 已正确声明了 `unique`, `filterable`, `sortable`, `searchable` 标记。`create_table` 方法会自动根据这些标记创建 Generated Column 和索引。**无需修改 plugin.toml。**
验证:重新安装 CRM 插件后,`plugin_erp_crm_customer` 表应自动包含 `_f_code`, `_f_name`, `_f_customer_type`, `_f_status`, `_f_level` 等 Generated Column。
- [ ] **Step 2: 集成验证**
Run: 启动后端服务 `cargo run -p erp-server`,调用 API 卸载+重新安装 CRM 插件
Expected: 动态表包含 Generated Columnpg_trgm 索引已创建
- [ ] **Step 3: 提交(如有改动)**
```bash
git add crates/erp-plugin-crm/plugin.toml
git commit -m "chore(crm): 验证 Generated Column 自动生成 — 无需修改 plugin.toml"
```
---
## Chunk 2: 数据完整性框架
### Task 2.1: manifest.rs — 扩展 PluginField (ref_entity / validation / no_cycle)
**Files:**
- Modify: `crates/erp-plugin/src/manifest.rs:49-69` (PluginField struct)
- [ ] **Step 1: 写失败测试**
```rust
#[test]
fn parse_field_with_ref_entity() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "contact"
display_name = "联系人"
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "所属客户"
ref_entity = "customer"
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
}
#[test]
fn parse_field_with_validation() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "contact"
display_name = "联系人"
[[schema.entities.fields]]
name = "phone"
field_type = "string"
display_name = "手机号"
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
let v = field.validation.as_ref().unwrap();
assert_eq!(v.pattern.as_deref(), Some("^1[3-9]\\d{9}$"));
assert_eq!(v.message.as_deref(), Some("手机号格式不正确"));
}
#[test]
fn parse_field_with_no_cycle() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "parent_id"
field_type = "uuid"
display_name = "上级客户"
ref_entity = "customer"
no_cycle = true
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
assert_eq!(field.no_cycle, Some(true));
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
}
```
- [ ] **Step 2: 验证测试失败**
Run: `cargo test -p erp-plugin -- parse_field_with_ref`
Expected: 编译失败(`ref_entity` 字段不存在)
- [ ] **Step 3: 扩展 PluginField 结构体**
`PluginField` 中新增三个字段:
```rust
pub struct PluginField {
// ... 已有字段 ...
pub ref_entity: Option<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()` 辅助方法:
```rust
pub fn default_for_field() -> Self {
Self {
// ... 已有字段 ...
ref_entity: None,
validation: None,
no_cycle: None,
}
}
```
- [ ] **Step 4: 运行测试**
Run: `cargo test -p erp-plugin`
Expected: 全部 PASS
- [ ] **Step 5: 提交**
```bash
git add crates/erp-plugin/src/manifest.rs
git commit -m "feat(plugin): PluginField 扩展 — ref_entity / validation / no_cycle"
```
---
### Task 2.2: manifest.rs — 新增 PluginRelation (级联删除声明)
**Files:**
- Modify: `crates/erp-plugin/src/manifest.rs` (PluginEntity + OnDeleteStrategy)
- [ ] **Step 1: 写失败测试**
```rust
#[test]
fn parse_entity_with_relations() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "code"
field_type = "string"
required = true
display_name = "编码"
[[schema.entities.relations]]
entity = "contact"
foreign_key = "customer_id"
on_delete = "cascade"
[[schema.entities.relations]]
entity = "customer_tag"
foreign_key = "customer_id"
on_delete = "cascade"
"#;
let manifest = parse_manifest(toml).unwrap();
let entity = &manifest.schema.unwrap().entities[0];
assert_eq!(entity.relations.len(), 2);
assert_eq!(entity.relations[0].entity, "contact");
assert_eq!(entity.relations[0].foreign_key, "customer_id");
assert!(matches!(entity.relations[0].on_delete, OnDeleteStrategy::Cascade));
}
```
- [ ] **Step 2: 实现 PluginRelation + OnDeleteStrategy**
```rust
/// 级联删除策略
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum OnDeleteStrategy {
Nullify, // 置空外键字段
Cascade, // 级联软删除
Restrict, // 存在关联时拒绝删除
}
/// 实体关联关系声明
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRelation {
pub entity: String, // 关联实体名
pub foreign_key: String, // 关联实体中的外键字段名
pub on_delete: OnDeleteStrategy, // 级联策略
}
```
`PluginEntity` 中添加:
```rust
pub struct PluginEntity {
pub name: String,
pub display_name: String,
#[serde(default)]
pub fields: Vec<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: 提交**
```bash
git add crates/erp-plugin/src/manifest.rs
git commit -m "feat(plugin): PluginRelation 级联删除声明 + OnDeleteStrategy"
```
---
### Task 2.3: data_service.rs — 外键校验 (ref_entity)
**Files:**
- Modify: `crates/erp-plugin/src/data_service.rs` (validate_data 扩展)
- [ ] **Step 1: 写失败测试**
```rust
#[tokio::test]
async fn test_validate_ref_entity_rejects_missing_reference() {
// 简单验证逻辑单元测试ref_entity 字段指向不存在的记录时应报错
// 注意:此测试需要数据库连接,标记为集成测试或使用 mock
// 此处先验证 validate_ref_entities 函数签名存在
}
```
> 由于外键校验需要数据库查询,此任务更适合集成测试。此处先实现逻辑,集成测试在手动验证阶段进行。
- [ ] **Step 2: 实现 ref_entity 校验函数**
`data_service.rs` 中新增:
```rust
/// 校验外键引用 — 检查 ref_entity 字段指向的记录是否存在
async fn validate_ref_entities(
data: &serde_json::Value,
fields: &[PluginField],
current_entity: &str,
plugin_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
is_create: bool,
record_id: Option<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`
```rust
#[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` 调用之后添加:
```rust
validate_ref_entities(&data, &fields, entity_name, plugin_id, tenant_id, db, true, None).await?;
```
`update` 方法中添加:
```rust
validate_ref_entities(&data, &fields, entity_name, plugin_id, tenant_id, db, false, Some(id)).await?;
```
- [ ] **Step 4: 编译验证**
Run: `cargo check -p erp-plugin`
Expected: 编译通过
- [ ] **Step 5: 提交**
```bash
git add crates/erp-plugin/src/data_service.rs
git commit -m "feat(plugin): 外键校验 — ref_entity 字段验证引用记录存在性"
```
---
### Task 2.4: data_service.rs — 字段校验 (validation.pattern)
**Files:**
- Modify: `crates/erp-plugin/src/data_service.rs` (validate_data 扩展)
- Modify: `crates/erp-plugin/Cargo.toml` (添加 regex 依赖)
- [ ] **Step 1: 添加 regex 依赖**
`Cargo.toml` 中添加:
```toml
regex = "1"
```
- [ ] **Step 2: 扩展 validate_data**
修改 `validate_data` 函数,在 required 检查之后增加正则校验:
```rust
fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> {
let obj = data.as_object().ok_or_else(|| {
AppError::Validation("data 必须是 JSON 对象".to_string())
})?;
for field in fields {
let label = field.display_name.as_deref().unwrap_or(&field.name);
// required 检查
if field.required && !obj.contains_key(&field.name) {
return Err(AppError::Validation(format!("字段 '{}' 不能为空", label)));
}
// 正则校验
if let Some(validation) = &field.validation {
if let Some(pattern) = &validation.pattern {
if let Some(val) = obj.get(&field.name) {
let str_val = val.as_str().unwrap_or("");
if !str_val.is_empty() {
let re = regex::Regex::new(pattern)
.map_err(|e| AppError::Internal(format!("正则表达式编译失败: {}", e)))?;
if !re.is_match(str_val) {
let msg = validation.message.as_deref()
.unwrap_or(&format!("字段 '{}' 格式不正确", label));
return Err(AppError::Validation(msg.to_string()));
}
}
}
}
}
}
Ok(())
}
```
- [ ] **Step 3: 写单元测试**
```rust
#[cfg(test)]
mod validate_tests {
use super::*;
use crate::manifest::{FieldValidation, PluginField, PluginFieldType};
fn make_field(name: &str, pattern: Option<&str>, message: Option<&str>) -> PluginField {
PluginField {
name: name.to_string(),
field_type: PluginFieldType::String,
required: false,
validation: pattern.map(|p| FieldValidation {
pattern: Some(p.to_string()),
message: message.map(|m| m.to_string()),
}),
..PluginField::default_for_field()
}
}
#[test]
fn validate_phone_pattern_rejects_invalid() {
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), Some("手机号格式不正确"))];
let data = serde_json::json!({"phone": "1234"});
let result = validate_data(&data, &fields);
assert!(result.is_err());
}
#[test]
fn validate_phone_pattern_accepts_valid() {
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), Some("手机号格式不正确"))];
let data = serde_json::json!({"phone": "13812345678"});
let result = validate_data(&data, &fields);
assert!(result.is_ok());
}
#[test]
fn validate_empty_optional_field_skips_pattern() {
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), None)];
let data = serde_json::json!({"phone": ""});
let result = validate_data(&data, &fields);
assert!(result.is_ok()); // 空值 + 非 required → 跳过校验
}
}
```
- [ ] **Step 4: 运行测试**
Run: `cargo test -p erp-plugin -- validate`
Expected: 全部 PASS
- [ ] **Step 5: 提交**
```bash
git add crates/erp-plugin/src/data_service.rs crates/erp-plugin/Cargo.toml
git commit -m "feat(plugin): 字段正则校验 — validation.pattern 支持"
```
---
### Task 2.5: data_service.rs — 循环引用检测 (no_cycle)
**Files:**
- Modify: `crates/erp-plugin/src/data_service.rs`
- [ ] **Step 1: 写失败测试**
```rust
#[test]
fn detect_cycle_in_self_reference() {
// 循环检测是纯逻辑A.parent_id = B, B.parent_id = A → 检测到循环
// 需要数据库查询链,此处测试 detect_cycle 函数签名
}
```
> 循环检测需要数据库交互,此处直接实现,手动集成测试验证。
- [ ] **Step 2: 实现循环检测**
`data_service.rs` 中新增:
```rust
/// 循环引用检测 — 用于 no_cycle 字段
/// 从当前记录开始,沿 ref_entity 链向上追溯,检测是否形成环
async fn check_no_cycle(
record_id: Uuid,
field: &PluginField,
data: &serde_json::Value,
table_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AppResult<()> {
let Some(val) = data.get(&field.name) else { return Ok(()) };
let new_parent = val.as_str().unwrap_or("").trim().to_string();
if new_parent.is_empty() {
return Ok(());
}
let new_parent_id = Uuid::parse_str(&new_parent).map_err(|_| {
AppError::Validation("parent_id 不是有效的 UUID".to_string())
})?;
let field_name = sanitize_identifier(&field.name);
let mut visited = vec![record_id];
let mut current_id = new_parent_id;
// 最多追溯 100 层,防止无限循环
for _ in 0..100 {
if visited.contains(&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` 之后添加:
```rust
// 循环引用检测
for field in &fields {
if field.no_cycle == Some(true) && data.get(&field.name).is_some() {
check_no_cycle(id, field, &data, &info.table_name, tenant_id, db).await?;
}
}
```
- [ ] **Step 4: 编译验证**
Run: `cargo check -p erp-plugin`
Expected: 编译通过
- [ ] **Step 5: 提交**
```bash
git add crates/erp-plugin/src/data_service.rs
git commit -m "feat(plugin): 循环引用检测 — no_cycle 字段支持"
```
---
### Task 2.6: data_service.rs — 级联删除 (relations)
**Files:**
- Modify: `crates/erp-plugin/src/data_service.rs` (delete 方法)
- [ ] **Step 1: 实现级联删除**
修改 `delete` 方法:
```rust
/// 删除(软删除)— 支持级联策略
pub async fn delete(
plugin_id: Uuid,
entity_name: &str,
id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
cache: &moka::sync::Cache<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: 提交**
```bash
git add crates/erp-plugin/src/data_service.rs
git commit -m "feat(plugin): 级联删除 — Restrict/Nullify/Cascade 三种策略"
```
---
### Task 2.7: CRM plugin.toml — 添加 ref_entity / relations / validation / no_cycle
**Files:**
- Modify: `crates/erp-plugin-crm/plugin.toml`
- [ ] **Step 1: 更新 contact 实体 — customer_id 添加 ref_entity**
将 contact 的 customer_id 字段改为:
```toml
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "所属客户"
ref_entity = "customer"
```
- [ ] **Step 2: 更新 communication 实体 — 添加 ref_entity**
```toml
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "关联客户"
ref_entity = "customer"
[[schema.entities.fields]]
name = "contact_id"
field_type = "uuid"
display_name = "关联联系人"
ref_entity = "contact"
```
- [ ] **Step 3: 更新 customer_tag / customer_relationship — 添加 ref_entity**
```toml
# customer_tag
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "关联客户"
ref_entity = "customer"
# customer_relationship
[[schema.entities.fields]]
name = "from_customer_id"
field_type = "uuid"
required = true
display_name = "源客户"
ref_entity = "customer"
[[schema.entities.fields]]
name = "to_customer_id"
field_type = "uuid"
required = true
display_name = "目标客户"
ref_entity = "customer"
```
- [ ] **Step 4: customer 实体 — 添加 relations + no_cycle + validation**
```toml
# customer 的 parent_id 字段添加 ref_entity + no_cycle
[[schema.entities.fields]]
name = "parent_id"
field_type = "uuid"
display_name = "上级客户"
ref_entity = "customer"
no_cycle = true
# customer 实体的 relations
[[schema.entities.relations]]
entity = "contact"
foreign_key = "customer_id"
on_delete = "nullify"
[[schema.entities.relations]]
entity = "communication"
foreign_key = "customer_id"
on_delete = "cascade"
[[schema.entities.relations]]
entity = "customer_tag"
foreign_key = "customer_id"
on_delete = "cascade"
```
contact 的 phone/email 添加 validation
```toml
[[schema.entities.fields]]
name = "phone"
field_type = "string"
display_name = "手机号"
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
[[schema.entities.fields]]
name = "email"
field_type = "string"
display_name = "邮箱"
validation = { pattern = "^[\\w.-]+@[\\w.-]+\\.\\w+$", message = "邮箱格式不正确" }
```
- [ ] **Step 5: 验证 TOML 解析**
Run: `cargo test -p erp-plugin -- parse_full_manifest`
Expected: PASS
- [ ] **Step 6: 提交**
```bash
git add crates/erp-plugin-crm/plugin.toml
git commit -m "feat(crm): 添加 ref_entity / relations / validation / no_cycle 声明"
```
---
## Chunk 3: 行级数据权限
### Task 3.1: 数据库迁移 — role_permissions 添加 data_scope 列 + 权限补偿
**Files:**
- Create: `crates/erp-server/migration/src/m20260418_000036_add_data_scope_to_role_permissions.rs`
- Modify: `crates/erp-server/migration/src/lib.rs`
- [ ] **Step 1: 创建迁移文件**
```rust
// crates/erp-server/migration/src/m20260418_000036_add_data_scope_to_role_permissions.rs
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 添加 data_scope 列
manager
.alter_table(
Table::alter()
.table(Alias::new("role_permissions"))
.add_column(
ColumnDef::new(Alias::new("data_scope"))
.string()
.not_null()
.default("all"),
)
.to_owned(),
)
.await?;
// 权限补偿:为所有拥有 plugin.admin 权限的角色,自动分配所有已安装插件的实体级权限
// data_scope 默认 'all'(管理员级别)
let conn = manager.get_connection();
conn.execute_unprepared(
r#"
INSERT INTO role_permissions (id, role_id, permission_id, tenant_id, data_scope, created_at, updated_at)
SELECT gen_random_uuid(), rp.role_id, p.id, rp.tenant_id, 'all', NOW(), NOW()
FROM role_permissions rp
JOIN permissions p ON p.tenant_id = rp.tenant_id
JOIN role_permissions rp_src ON rp_src.role_id = rp.role_id
JOIN permissions p_src ON p_src.id = rp.permission_id AND p_src.code = 'plugin.admin'
WHERE p.code LIKE 'erp-%'
AND NOT EXISTS (
SELECT 1 FROM role_permissions rp2
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p.id
)
GROUP BY rp.role_id, p.id, rp.tenant_id
"#
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("role_permissions"))
.drop_column(Alias::new("data_scope"))
.to_owned(),
)
.await
}
}
```
- [ ] **Step 2: 注册迁移**
`lib.rs` 中添加 `mod m20260418_000036_add_data_scope_to_role_permissions;` 和对应的 `Box::new(...)`
- [ ] **Step 3: 验证**
Run: `cargo check -p erp-server`
Expected: 编译通过
- [ ] **Step 4: 提交**
```bash
git add crates/erp-server/migration/src/m20260418_000036_add_data_scope_to_role_permissions.rs crates/erp-server/migration/src/lib.rs
git commit -m "feat(db): role_permissions 添加 data_scope 列 + plugin.admin 权限补偿"
```
---
### Task 3.2: erp-core — TenantContext 扩展 department_ids
**Files:**
- Modify: `crates/erp-core/src/types.rs:150-158` (TenantContext)
- [ ] **Step 1: 写失败测试**
```rust
#[test]
fn tenant_context_with_departments() {
let ctx = TenantContext {
tenant_id: Uuid::now_v7(),
user_id: Uuid::now_v7(),
roles: vec!["admin".to_string()],
permissions: vec!["user.read".to_string()],
department_ids: vec![Uuid::now_v7(), Uuid::now_v7()],
};
assert_eq!(ctx.department_ids.len(), 2);
}
```
- [ ] **Step 2: 修改 TenantContext**
```rust
/// 租户上下文(中间件注入)
#[derive(Debug, Clone)]
pub struct TenantContext {
pub tenant_id: Uuid,
pub user_id: Uuid,
pub roles: Vec<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: 提交**
```bash
git add crates/erp-core/src/types.rs crates/erp-auth/src/middleware/jwt_auth.rs
git commit -m "feat(core): TenantContext 新增 department_ids 字段"
```
---
### Task 3.3: erp-auth — JWT 中间件填充 department_ids
**Files:**
- Modify: `crates/erp-auth/src/middleware/jwt_auth.rs`
- [ ] **Step 1: 修改 JWT claims 解析**
在 JWT 中间件中,从 token claims 或数据库查询用户的部门 ID
```rust
// 在构造 TenantContext 时,查询用户所属部门
let department_ids = {
// 通过 user_positions 表关联 departments 表查询
let positions = user_position::Entity::find()
.filter(user_position::Column::UserId.eq(user_id))
.filter(user_position::Column::TenantId.eq(tenant_id))
.all(db)
.await
.unwrap_or_default();
positions.iter().map(|p| p.department_id).collect()
};
TenantContext {
tenant_id,
user_id,
roles,
permissions,
department_ids,
}
```
> **注意:** 具体查询逻辑取决于 `erp-auth` 中 Position/Department 的 SeaORM Entity 定义。需先确认 entity 路径。
- [ ] **Step 2: 编译验证**
Run: `cargo check --workspace`
Expected: 编译通过
- [ ] **Step 3: 提交**
```bash
git add crates/erp-auth/src/middleware/jwt_auth.rs
git commit -m "feat(auth): JWT 中间件填充 department_ids"
```
---
### Task 3.4: manifest.rs — 实体级 data_scope 声明
**Files:**
- Modify: `crates/erp-plugin/src/manifest.rs` (PluginEntity, PluginField, PluginPermission)
- [ ] **Step 1: 写失败测试**
```rust
#[test]
fn parse_entity_with_data_scope() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
data_scope = true
[[schema.entities.fields]]
name = "owner_id"
field_type = "uuid"
display_name = "负责人"
scope_role = "owner"
[[permissions]]
code = "customer.list"
name = "查看客户"
data_scope_levels = ["self", "department", "department_tree", "all"]
"#;
let manifest = parse_manifest(toml).unwrap();
let entity = &manifest.schema.unwrap().entities[0];
assert_eq!(entity.data_scope, Some(true));
assert_eq!(entity.fields[0].scope_role.as_deref(), Some("owner"));
let perm = &manifest.permissions.unwrap()[0];
assert_eq!(perm.data_scope_levels.as_ref().unwrap().len(), 4);
}
```
- [ ] **Step 2: 扩展结构体**
```rust
pub struct PluginEntity {
pub name: String,
pub display_name: String,
pub fields: Vec<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: 提交**
```bash
git add crates/erp-plugin/src/manifest.rs
git commit -m "feat(plugin): 实体级 data_scope + scope_role + data_scope_levels 声明"
```
---
### Task 3.5: dynamic_table.rs — SQL 构建支持数据范围条件
**Files:**
- Modify: `crates/erp-plugin/src/dynamic_table.rs`
- [ ] **Step 1: 写失败测试**
```rust
#[test]
fn test_build_data_scope_condition_self() {
let condition = DynamicTableManager::build_data_scope_condition(
"self",
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"owner_id",
&[],
);
assert!(condition.contains("owner_id"), "self 应包含 owner_id 条件");
}
#[test]
fn test_build_data_scope_condition_department() {
let dept_members = vec![
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(),
];
let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params(
"department",
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"owner_id",
&dept_members,
2, // 起始参数索引
);
assert!(sql.contains("IN"), "department 应使用 IN 条件");
assert!(sql.contains("$2"), "参数索引应正确");
}
```
- [ ] **Step 2: 实现数据范围 SQL 构建**
```rust
/// 构建数据范围 SQL 条件
/// 返回 (条件 SQL, 附加参数)
pub fn build_data_scope_condition_with_params(
scope_level: &str,
current_user_id: &Uuid,
owner_field: &str,
dept_member_ids: &[Uuid],
start_param_idx: usize,
generated_fields: &[String], // C4 修复:接受 generated_fields 参数
) -> (String, Vec<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: 提交**
```bash
git add crates/erp-plugin/src/dynamic_table.rs
git commit -m "feat(plugin): SQL 构建支持行级数据范围条件"
```
---
### Task 3.6: data_handler.rs — 移除权限 fallback
**Files:**
- Modify: `crates/erp-plugin/src/handler/data_handler.rs`
- [ ] **Step 1: 修改权限检查逻辑**
将所有 handler 中的两级 fallback 改为单级检查:
**当前(危险):**
```rust
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.list")?;
}
```
**修改后:**
```rust
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
require_permission(&ctx, &fine_perm)?;
```
对所有 handler 方法list, create, get, update, delete, count, aggregate执行相同的修改
- `create``update``delete`:删除 `plugin.admin` fallback
- `list``get``count``aggregate`:删除 `plugin.list` fallback
- [ ] **Step 2: 编译验证**
Run: `cargo check -p erp-plugin`
Expected: 编译通过
- [ ] **Step 3: 提交**
```bash
git add crates/erp-plugin/src/handler/data_handler.rs
git commit -m "fix(plugin): 移除权限 fallback — 必须显式分配实体级权限"
```
---
### Task 3.7: data_service.rs — 查询注入数据范围
**Files:**
- Modify: `crates/erp-plugin/src/data_service.rs` (list/count/aggregate 方法)
- [ ] **Step 1: 在 list 方法中注入数据范围条件**
**Handler 层data_handler.rs— 获取 data_scope 等级并查询部门用户:**
```rust
/// 获取当前用户对指定权限的 data_scope 等级
async fn get_data_scope(
ctx: &TenantContext,
permission_code: &str,
db: &DatabaseConnection,
) -> AppResult<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 中调用:**
```rust
// 获取 data_scope 等级
let data_scope = get_data_scope(&ctx, &fine_perm, &state.db).await?;
// 获取部门成员(仅 department/department_tree 级别需要)
let dept_members = match data_scope.as_str() {
"department" => get_dept_members(&ctx, &state.db, false).await?,
"department_tree" => get_dept_members(&ctx, &state.db, true).await?,
_ => vec![],
};
// 传入 data_service
let (items, total, next_cursor) = PluginDataService::list_with_scope(
plugin_id, &entity, ctx.tenant_id, page, page_size,
&state.db, &state.entity_cache,
filter, params.search, params.sort_by, params.sort_order, params.cursor,
&data_scope, &ctx.user_id, &dept_members,
).await?;
```
**data_service.rs 新增 `list_with_scope` 方法:**
`build_filtered_query_sql_ex` 的条件列表中追加 data_scope 条件:
```rust
pub async fn list_with_scope(
/* ... 原有参数 ... */
data_scope: &str,
current_user_id: &Uuid,
dept_members: &[Uuid],
) -> AppResult<(Vec<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: 提交**
```bash
git add crates/erp-plugin/src/data_service.rs crates/erp-plugin/src/handler/data_handler.rs
git commit -m "feat(plugin): 查询注入行级数据范围条件"
```
---
### Task 3.8: CRM plugin.toml — 添加 data_scope / owner_id
**Files:**
- Modify: `crates/erp-plugin-crm/plugin.toml`
- [ ] **Step 1: customer 实体启用 data_scope**
```toml
[[schema.entities]]
name = "customer"
display_name = "客户"
data_scope = true
# 新增 owner_id 字段
[[schema.entities.fields]]
name = "owner_id"
field_type = "uuid"
display_name = "负责人"
scope_role = "owner"
```
- [ ] **Step 2: 权限声明添加 data_scope_levels**
```toml
[[permissions]]
code = "customer.list"
name = "查看客户"
description = "查看客户列表和详情"
data_scope_levels = ["self", "department", "department_tree", "all"]
```
`contact.list`, `communication.list` 等也添加 data_scope_levels。
- [ ] **Step 3: 验证**
Run: `cargo test -p erp-plugin -- parse_full_manifest`
Expected: PASS
- [ ] **Step 4: 提交**
```bash
git add crates/erp-plugin-crm/plugin.toml
git commit -m "feat(crm): customer 实体启用 data_scope + owner_id 字段"
```
---
## Chunk 4: 前端页面能力增强
### Task 4.1: 后端 — PATCH 部分更新端点 + build_patch_sql
**Files:**
- Modify: `crates/erp-plugin/src/dynamic_table.rs` (新增 build_patch_sql)
- Modify: `crates/erp-plugin/src/data_service.rs` (新增 partial_update)
- Modify: `crates/erp-plugin/src/data_dto.rs` (新增 PatchPluginDataReq)
- Modify: `crates/erp-plugin/src/handler/data_handler.rs` (新增 patch_plugin_data handler)
- Modify: `crates/erp-plugin/src/handler/plugin_handler.rs` (注册 PATCH 路由)
- [ ] **Step 1: 写失败测试**
```rust
#[test]
fn test_build_patch_sql_merges_fields() {
let (sql, values) = DynamicTableManager::build_patch_sql(
"plugin_test_customer",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
Uuid::parse_str("00000000-0000-0000-0000-000000000099").unwrap(),
Uuid::parse_str("00000000-0000-0000-0000-000000000050").unwrap(),
serde_json::json!({"level": "vip", "status": "active"}),
3,
);
assert!(sql.contains("jsonb_set"), "PATCH 应使用 jsonb_set 合并");
assert!(sql.contains("version = version + 1"), "PATCH 应更新版本号");
}
```
- [ ] **Step 2: 新增 PatchPluginDataReq**
`data_dto.rs` 中添加:
```rust
/// 部分更新请求
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PatchPluginDataReq {
pub data: serde_json::Value,
pub version: i32,
}
```
- [ ] **Step 3: 实现 build_patch_sql**
`dynamic_table.rs` 中:
```rust
/// 构建 PATCH SQL — 只更新 data 中提供的字段,未提供的保持不变
pub fn build_patch_sql(
table_name: &str,
id: Uuid,
tenant_id: Uuid,
user_id: Uuid,
partial_data: serde_json::Value,
version: i32,
) -> (String, Vec<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**
```rust
/// 部分更新PATCH
pub async fn partial_update(
plugin_id: Uuid,
entity_name: &str,
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
partial_data: serde_json::Value,
expected_version: i32,
db: &sea_orm::DatabaseConnection,
cache: &moka::sync::Cache<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**
```rust
#[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: 提交**
```bash
git add crates/erp-plugin/src/dynamic_table.rs crates/erp-plugin/src/data_service.rs crates/erp-plugin/src/data_dto.rs crates/erp-plugin/src/handler/data_handler.rs crates/erp-plugin/src/handler/plugin_handler.rs
git commit -m "feat(plugin): PATCH 部分更新端点 — jsonb_set 字段合并"
```
---
### Task 4.2: 后端 — 批量操作端点
**Files:**
- Modify: `crates/erp-plugin/src/data_dto.rs` (BatchActionReq)
- Modify: `crates/erp-plugin/src/data_service.rs` (batch 操作)
- Modify: `crates/erp-plugin/src/handler/data_handler.rs` (batch handler)
- [ ] **Step 1: 新增 DTO**
```rust
/// 批量操作请求
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct BatchActionReq {
pub action: String, // "batch_delete" 或 "batch_update"
pub ids: Vec<String>, // 记录 ID 列表(上限 100
pub data: Option<serde_json::Value>, // batch_update 时的更新数据
}
```
- [ ] **Step 2: 实现 batch 操作**
`data_service.rs` 中:
```rust
/// 批量操作(单个事务)
pub async fn batch(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
operator_id: Uuid,
req: BatchActionReq,
db: &sea_orm::DatabaseConnection,
cache: &moka::sync::Cache<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 注册**
```rust
#[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: 编译验证 + 提交**
```bash
git add crates/erp-plugin/src/
git commit -m "feat(plugin): 批量操作端点 — batch_delete + batch_update"
```
---
### Task 4.3: 后端 — timeseries 聚合 API
**Files:**
- Modify: `crates/erp-plugin/src/data_dto.rs` (TimeseriesParams)
- Modify: `crates/erp-plugin/src/dynamic_table.rs` (build_timeseries_sql)
- Modify: `crates/erp-plugin/src/data_service.rs` (timeseries 方法)
- Modify: `crates/erp-plugin/src/handler/data_handler.rs` (timeseries handler)
- [ ] **Step 1: 新增 DTO + SQL 构建 + service + handler**
```rust
// data_dto.rs
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
pub struct TimeseriesParams {
pub time_field: String, // 时间字段名
pub time_grain: String, // "day" / "week" / "month"
pub start: Option<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: 编译验证 + 提交**
```bash
git add crates/erp-plugin/src/
git commit -m "feat(plugin): timeseries 聚合 API — date_trunc 时间序列"
```
---
### Task 4.4: 前端 — API 层扩展
**Files:**
- Modify: `apps/web/src/api/plugins.ts` (Schema 类型扩展)
- Modify: `apps/web/src/api/pluginData.ts` (新增 batch/timeseries/cursor API)
- [ ] **Step 1: plugins.ts 类型扩展**
```typescript
// PluginFieldSchema 扩展
export interface PluginFieldSchema {
// ... 已有字段 ...
ref_entity?: string;
ref_label_field?: string;
ref_search_fields?: string[];
cascade_from?: string;
cascade_filter?: string;
}
// PluginPageType 新增 Kanban
// 在 tagged union 中添加:
| {
type: 'kanban';
entity: string;
label: string;
icon?: string;
lane_field: string;
lane_order?: string[];
card_title_field: string;
card_subtitle_field?: string;
card_fields?: string[];
enable_drag?: boolean;
}
// Dashboard widgets
export interface DashboardWidget {
type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart';
entity: string;
title: string;
icon?: string;
color?: string;
dimension_field?: string;
dimension_order?: string[];
metric?: string;
}
```
- [ ] **Step 2: pluginData.ts API 扩展**
```typescript
// 批量操作
export async function batchPluginData(
pluginId: string, entity: string, req: { action: string; ids: string[]; data?: any }
) {
const { data } = await api.post(`/api/v1/plugins/${pluginId}/${entity}/batch`, req);
return data;
}
// 部分更新
export async function patchPluginData(
pluginId: string, entity: string, id: string, req: { data: any; version: number }
) {
const { data } = await api.patch(`/api/v1/plugins/${pluginId}/${entity}/${id}`, req);
return data;
}
// 时间序列
export async function getPluginTimeseries(
pluginId: string, entity: string, params: {
time_field: string; time_grain: string; start?: string; end?: string;
}
) {
const { data } = await api.get(`/api/v1/plugins/${pluginId}/${entity}/timeseries`, { params });
return data;
}
```
- [ ] **Step 3: 提交**
```bash
git add apps/web/src/api/plugins.ts apps/web/src/api/pluginData.ts
git commit -m "feat(web): API 层扩展 — batch/patch/timeseries/kanban 类型"
```
---
### Task 4.5: 前端 — EntitySelect 关联选择器组件
**Files:**
- Create: `apps/web/src/components/EntitySelect.tsx`
- [ ] **Step 1: 实现 EntitySelect 组件**
```tsx
// apps/web/src/components/EntitySelect.tsx
import { Select, Spin } from 'antd';
import { useState, useEffect, useCallback } from 'react';
import { listPluginData } from '../api/pluginData';
interface EntitySelectProps {
pluginId: string;
entity: string;
labelField: string;
searchFields?: string[];
value?: string;
onChange?: (value: string, label: string) => void;
cascadeFrom?: string; // 级联过滤来源字段
cascadeFilter?: string; // 级联过滤目标字段
cascadeValue?: string; // 来源字段的当前值
placeholder?: string;
}
export default function EntitySelect({
pluginId, entity, labelField, searchFields,
value, onChange, cascadeFrom, cascadeFilter, cascadeValue,
placeholder,
}: EntitySelectProps) {
const [options, setOptions] = useState<{value: string; label: string}[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const fetchData = useCallback(async (keyword?: string) => {
setLoading(true);
try {
let filter: Record<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`
```tsx
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: 提交**
```bash
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: 实现表达式解析器**
```typescript
// 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: 提交**
```bash
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 组件**
```tsx
// 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 页面类型的匹配:
```tsx
case 'kanban':
return <PluginKanbanPage pluginId={pluginId} page={pageConfig} />;
```
- [ ] **Step 4: 提交**
```bash
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 组件中添加:
```tsx
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: 实现批量操作函数**
```tsx
const handleBatchDelete = async (ids: string[]) => {
await batchPluginData(pluginId, entity, { action: 'batch_delete', ids });
message.success('批量删除成功');
setSelectedRowKeys([]);
fetchData();
};
```
- [ ] **Step 3: 提交**
```bash
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 声明渲染不同类型的图表:
```tsx
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` 并行加载:
```tsx
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: 提交**
```bash
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: 提交**
```bash
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**
```toml
[[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**
```toml
[[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 页面**
```toml
[[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: 提交**
```bash
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: 最终提交**
```bash
git add -A
git commit -m "chore: CRM 插件基座升级 — 集成验证通过"
```