Chunk 1: JSONB 存储优化 (Generated Column + pg_trgm + Keyset + Schema 缓存) Chunk 2: 数据完整性框架 (ref_entity + 级联删除 + 字段校验 + 循环检测) Chunk 3: 行级数据权限 (data_scope + TenantContext 扩展 + fallback 收紧) Chunk 4: 前端页面能力增强 (entity_select + kanban + 批量操作 + 图表)
3797 lines
116 KiB
Markdown
3797 lines
116 KiB
Markdown
# CRM 插件基座升级实施计划
|
||
|
||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 升级 CRM 插件基座 — JSONB 存储优化、数据完整性框架、行级数据权限、前端页面能力增强
|
||
|
||
**Architecture:** 基座优先策略,所有改进沉淀为插件平台通用能力。后端 Rust (Axum + SeaORM + PostgreSQL),前端 React + Ant Design。依赖顺序:Generated Column → ref_entity → data_scope → 前端增强。
|
||
|
||
**Tech Stack:** Rust/Axum, PostgreSQL 16 (Generated Column + pg_trgm), Redis 7 (缓存), React 19 + Ant Design 6 + @ant-design/charts, @dnd-kit
|
||
|
||
**设计规格:** `docs/superpowers/specs/2026-04-17-crm-plugin-base-upgrade-design.md`
|
||
|
||
---
|
||
|
||
## 依赖关系图
|
||
|
||
```
|
||
Chunk 1 (JSONB 存储) ──┬──→ Chunk 2 (数据完整性)
|
||
├──→ Chunk 3 (行级权限)
|
||
└──→ Chunk 4 (前端增强)
|
||
│ │ │
|
||
│ pg_trgm 迁移 │ manifest 扩展 │ TenantContext 扩展
|
||
│ Generated Column │ ref_entity/relations │ data_scope
|
||
│ Keyset Pagination │ validation/no_cycle │ permission fallback
|
||
│ Schema 缓存 │ cascade delete │
|
||
│ 聚合 Redis 缓存 │ │
|
||
```
|
||
|
||
Chunk 1 是其他所有 Chunk 的基础。Chunk 2 和 Chunk 3 互相独立可并行。Chunk 4 依赖 Chunk 1-3 的后端 API。
|
||
|
||
---
|
||
|
||
## Chunk 1: JSONB 存储优化
|
||
|
||
### Task 1.1: 数据库迁移 — pg_trgm 扩展 + plugin_entity_columns 元数据表
|
||
|
||
**Files:**
|
||
- Create: `crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs`
|
||
- Modify: `crates/erp-server/migration/src/lib.rs`
|
||
|
||
- [ ] **Step 1: 创建迁移文件**
|
||
|
||
```rust
|
||
// crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs
|
||
use sea_orm_migration::prelude::*;
|
||
|
||
#[derive(DeriveMigrationName)]
|
||
pub struct Migration;
|
||
|
||
#[async_trait::async_trait]
|
||
impl MigrationTrait for Migration {
|
||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||
// 启用 pg_trgm 扩展(加速 ILIKE '%keyword%' 搜索)
|
||
manager
|
||
.get_connection()
|
||
.execute_unprepared("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||
.await?;
|
||
|
||
// 插件实体列元数据表 — 记录哪些字段被提取为 Generated Column
|
||
manager
|
||
.create_table(
|
||
Table::create()
|
||
.table(Alias::new("plugin_entity_columns"))
|
||
.if_not_exists()
|
||
.col(
|
||
ColumnDef::new(Alias::new("id"))
|
||
.uuid()
|
||
.not_null()
|
||
.default(Expr::cust("gen_random_uuid()"))
|
||
.primary_key(),
|
||
)
|
||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||
.col(ColumnDef::new(Alias::new("plugin_entity_id")).uuid().not_null())
|
||
.col(ColumnDef::new(Alias::new("field_name")).string().not_null())
|
||
.col(ColumnDef::new(Alias::new("column_name")).string().not_null())
|
||
.col(ColumnDef::new(Alias::new("sql_type")).string().not_null())
|
||
.col(
|
||
ColumnDef::new(Alias::new("is_generated"))
|
||
.boolean()
|
||
.not_null()
|
||
.default(true),
|
||
)
|
||
.col(
|
||
ColumnDef::new(Alias::new("created_at"))
|
||
.timestamp_with_time_zone()
|
||
.not_null()
|
||
.default(Expr::cust("NOW()")),
|
||
)
|
||
.to_owned(),
|
||
)
|
||
.await?;
|
||
|
||
// plugin_entity_id 外键
|
||
manager
|
||
.create_foreign_key(
|
||
ForeignKey::create()
|
||
.name("fk_plugin_entity_columns_entity")
|
||
.from(
|
||
Alias::new("plugin_entity_columns"),
|
||
Alias::new("plugin_entity_id"),
|
||
)
|
||
.to(Alias::new("plugin_entities"), Alias::new("id"))
|
||
.to_owned(),
|
||
)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||
manager
|
||
.drop_table(
|
||
Table::drop()
|
||
.table(Alias::new("plugin_entity_columns"))
|
||
.to_owned(),
|
||
)
|
||
.await?;
|
||
// pg_trgm 不卸载(其他功能可能依赖)
|
||
Ok(())
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 注册迁移**
|
||
|
||
在 `crates/erp-server/migration/src/lib.rs` 中添加:
|
||
|
||
```rust
|
||
mod m20260418_000035_pg_trgm_and_entity_columns;
|
||
```
|
||
|
||
在 `migrations()` vec 末尾添加:
|
||
|
||
```rust
|
||
Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration),
|
||
```
|
||
|
||
- [ ] **Step 3: 验证迁移**
|
||
|
||
Run: `cargo check -p erp-server`
|
||
Expected: 编译通过
|
||
|
||
Run: `cargo test -p erp-server -- --test-threads=1`(或启动服务让迁移自动执行)
|
||
Expected: 迁移成功
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs crates/erp-server/migration/src/lib.rs
|
||
git commit -m "feat(db): 添加 pg_trgm 扩展 + plugin_entity_columns 元数据表"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1.2: manifest.rs — Generated Column 类型映射
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin/src/manifest.rs:49-84` (PluginField, PluginFieldType)
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
在 `crates/erp-plugin/src/manifest.rs` 的 `#[cfg(test)] mod tests` 中添加:
|
||
|
||
```rust
|
||
#[test]
|
||
fn field_type_to_sql_mapping() {
|
||
use crate::manifest::PluginFieldType;
|
||
assert_eq!(PluginFieldType::String.generated_sql_type(), "TEXT");
|
||
assert_eq!(PluginFieldType::Integer.generated_sql_type(), "INTEGER");
|
||
assert_eq!(PluginFieldType::Float.generated_sql_type(), "DOUBLE PRECISION");
|
||
assert_eq!(PluginFieldType::Decimal.generated_sql_type(), "NUMERIC");
|
||
assert_eq!(PluginFieldType::Boolean.generated_sql_type(), "BOOLEAN");
|
||
assert_eq!(PluginFieldType::Date.generated_sql_type(), "DATE");
|
||
assert_eq!(PluginFieldType::DateTime.generated_sql_type(), "TIMESTAMPTZ");
|
||
assert_eq!(PluginFieldType::Uuid.generated_sql_type(), "UUID");
|
||
assert_eq!(PluginFieldType::Json.generated_sql_type(), "TEXT"); // JSON 不适合 Generated Column
|
||
}
|
||
|
||
#[test]
|
||
fn field_type_generated_expression() {
|
||
use crate::manifest::PluginFieldType;
|
||
assert_eq!(PluginFieldType::String.generated_expr("name"), "data->>'name'");
|
||
assert_eq!(PluginFieldType::Integer.generated_expr("age"), "(data->>'age')::INTEGER");
|
||
assert_eq!(PluginFieldType::Uuid.generated_expr("ref_id"), "(data->>'ref_id')::UUID");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证测试失败**
|
||
|
||
Run: `cargo test -p erp-plugin -- generated_sql_type`
|
||
Expected: 编译失败(方法不存在)
|
||
|
||
- [ ] **Step 3: 实现 PluginFieldType 扩展**
|
||
|
||
在 `PluginFieldType` enum 定义之后添加 impl 块:
|
||
|
||
```rust
|
||
impl PluginFieldType {
|
||
/// Generated Column 的 SQL 类型
|
||
pub fn generated_sql_type(&self) -> &'static str {
|
||
match self {
|
||
Self::String | Self::Json => "TEXT",
|
||
Self::Integer => "INTEGER",
|
||
Self::Float => "DOUBLE PRECISION",
|
||
Self::Decimal => "NUMERIC",
|
||
Self::Boolean => "BOOLEAN",
|
||
Self::Date => "DATE",
|
||
Self::DateTime => "TIMESTAMPTZ",
|
||
Self::Uuid => "UUID",
|
||
}
|
||
}
|
||
|
||
/// Generated Column 的表达式 — TEXT 类型直接取值,其他类型做类型转换
|
||
pub fn generated_expr(&self, field_name: &str) -> String {
|
||
match self {
|
||
Self::String | Self::Json => format!("data->>'{}'", field_name),
|
||
_ => format!("(data->>'{}')::{}", field_name, self.generated_sql_type()),
|
||
}
|
||
}
|
||
|
||
/// 该类型是否适合生成 Generated Column(JSON 类型不适合)
|
||
pub fn supports_generated_column(&self) -> bool {
|
||
!matches!(self, Self::Json)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试验证通过**
|
||
|
||
Run: `cargo test -p erp-plugin -- generated`
|
||
Expected: 全部 PASS
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-plugin/src/manifest.rs
|
||
git commit -m "feat(plugin): PluginFieldType 添加 Generated Column 类型映射"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1.3: dynamic_table.rs — Generated Column DDL + 索引策略重写
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin/src/dynamic_table.rs:27-118` (create_table 方法)
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
在 `dynamic_table.rs` 的 `#[cfg(test)] mod tests` 中添加:
|
||
|
||
```rust
|
||
#[test]
|
||
fn test_build_create_table_sql_with_generated_columns() {
|
||
use crate::manifest::{PluginEntity, PluginField, PluginFieldType};
|
||
|
||
let entity = PluginEntity {
|
||
name: "customer".to_string(),
|
||
display_name: "客户".to_string(),
|
||
fields: vec![
|
||
PluginField {
|
||
name: "code".to_string(),
|
||
field_type: PluginFieldType::String,
|
||
required: true, unique: true,
|
||
display_name: Some("编码".to_string()),
|
||
searchable: Some(true),
|
||
..Default::default_for_field()
|
||
},
|
||
PluginField {
|
||
name: "level".to_string(),
|
||
field_type: PluginFieldType::String,
|
||
filterable: true,
|
||
display_name: Some("等级".to_string()),
|
||
..Default::default_for_field()
|
||
},
|
||
PluginField {
|
||
name: "sort_order".to_string(),
|
||
field_type: PluginFieldType::Integer,
|
||
sortable: true,
|
||
display_name: Some("排序".to_string()),
|
||
..Default::default_for_field()
|
||
},
|
||
],
|
||
indexes: vec![],
|
||
};
|
||
|
||
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
||
|
||
// 验证 Generated Column 存在
|
||
assert!(sql.contains("_f_code"), "应包含 _f_code Generated Column");
|
||
assert!(sql.contains("_f_level"), "应包含 _f_level Generated Column");
|
||
assert!(sql.contains("_f_sort_order"), "应包含 _f_sort_order Generated Column");
|
||
assert!(sql.contains("GENERATED ALWAYS AS"), "应包含 GENERATED ALWAYS AS");
|
||
// Integer 类型需要类型转换
|
||
assert!(sql.contains("::INTEGER"), "Integer 字段应有类型转换");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_create_table_sql_pg_trgm_search_index() {
|
||
use crate::manifest::{PluginEntity, PluginField, PluginFieldType};
|
||
|
||
let entity = PluginEntity {
|
||
name: "customer".to_string(),
|
||
display_name: "客户".to_string(),
|
||
fields: vec![
|
||
PluginField {
|
||
name: "name".to_string(),
|
||
field_type: PluginFieldType::String,
|
||
searchable: Some(true),
|
||
display_name: Some("名称".to_string()),
|
||
..Default::default_for_field()
|
||
},
|
||
],
|
||
indexes: vec![],
|
||
};
|
||
|
||
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
||
assert!(sql.contains("gin_trgm_ops"), "searchable 字段应使用 pg_trgm GIN 索引");
|
||
}
|
||
```
|
||
|
||
> **注意:** `PluginField` 当前没有 `Default` 实现。为每个新增字段维护 `default_for_field()` 容易遗漏。
|
||
> **推荐做法:** 直接为 `PluginField` 派生 `#[derive(Default)]`,并让 `PluginFieldType` 的 Default 为 `String`:
|
||
>
|
||
> ```rust
|
||
> #[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
> #[serde(rename_all = "snake_case")]
|
||
> pub enum PluginFieldType {
|
||
> #[default]
|
||
> String,
|
||
> Integer, Float, Decimal, Boolean, Date, DateTime, Json, Uuid,
|
||
> }
|
||
> ```
|
||
>
|
||
> 这样 `PluginField::default()` 自动包含所有字段的默认值,新增字段无需手动同步。如果派生 Default 不可行(因为 serde 需求),则使用以下手动辅助函数:
|
||
>
|
||
> ⚠️ **重要:** 每个扩展 `PluginField` 的 Task 都必须同步更新 `default_for_field()`,否则后续 Task 的测试会编译失败。
|
||
|
||
在 `PluginField` struct 的 `impl PluginField` 块中添加:
|
||
|
||
```rust
|
||
impl PluginField {
|
||
/// 用于测试的默认值构造器
|
||
/// ⚠️ 每次新增 PluginField 字段后必须同步更新此方法
|
||
#[cfg(test)]
|
||
pub fn default_for_field() -> Self {
|
||
Self {
|
||
name: String::new(),
|
||
field_type: PluginFieldType::String,
|
||
required: false,
|
||
unique: false,
|
||
default: None,
|
||
display_name: None,
|
||
ui_widget: None,
|
||
options: None,
|
||
searchable: None,
|
||
filterable: None,
|
||
sortable: None,
|
||
visible_when: None,
|
||
ref_entity: None, // Task 2.1 新增
|
||
validation: None, // Task 2.1 新增
|
||
no_cycle: None, // Task 2.1 新增
|
||
scope_role: None, // Task 3.4 新增
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证测试失败**
|
||
|
||
Run: `cargo test -p erp-plugin -- test_build_create_table`
|
||
Expected: 编译失败(`build_create_table_sql` 方法不存在,`default_for_field` 方法不存在)
|
||
|
||
- [ ] **Step 3: 重写 create_table 为 build_create_table_sql + 新的 create_table**
|
||
|
||
在 `DynamicTableManager` 中新增纯函数方法(不执行 SQL,只生成 DDL 字符串):
|
||
|
||
```rust
|
||
/// 生成包含 Generated Column 的建表 DDL
|
||
pub fn build_create_table_sql(plugin_id: &str, entity: &PluginEntity) -> String {
|
||
let table_name = Self::table_name(plugin_id, &entity.name);
|
||
let t = sanitize_identifier(&table_name);
|
||
|
||
let mut gen_cols = Vec::new();
|
||
let mut indexes = Vec::new();
|
||
|
||
for field in &entity.fields {
|
||
if !field.field_type.supports_generated_column() {
|
||
continue;
|
||
}
|
||
// 提取规则:unique / (required && (sortable||filterable)) / sortable / filterable
|
||
let should_extract = field.unique
|
||
|| field.sortable == Some(true)
|
||
|| field.filterable == Some(true)
|
||
|| (field.required && (field.sortable == Some(true) || field.filterable == Some(true)));
|
||
|
||
if !should_extract {
|
||
continue;
|
||
}
|
||
|
||
let col_name = format!("_f_{}", sanitize_identifier(&field.name));
|
||
let sql_type = field.field_type.generated_sql_type();
|
||
let expr = field.field_type.generated_expr(&sanitize_identifier(&field.name));
|
||
|
||
gen_cols.push(format!(
|
||
" \"{}\" {} GENERATED ALWAYS AS ({}) STORED",
|
||
col_name, sql_type, expr
|
||
));
|
||
|
||
// 索引策略
|
||
let col_idx = format!("{}_{}", sanitize_identifier(&table_name), col_name);
|
||
if field.unique {
|
||
indexes.push(format!(
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS \"idx_{}_uniq\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL",
|
||
col_idx, table_name, col_name
|
||
));
|
||
} else {
|
||
indexes.push(format!(
|
||
"CREATE INDEX IF NOT EXISTS \"idx_{}\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL",
|
||
col_idx, table_name, col_name
|
||
));
|
||
}
|
||
}
|
||
|
||
// pg_trgm 索引(searchable 字段)
|
||
for field in &entity.fields {
|
||
if field.searchable == Some(true) && matches!(field.field_type, PluginFieldType::String) {
|
||
let sf = sanitize_identifier(&field.name);
|
||
indexes.push(format!(
|
||
"CREATE INDEX IF NOT EXISTS \"idx_{}_{}_trgm\" ON \"{}\" USING GIN ((data->>'{}') gin_trgm_ops) WHERE deleted_at IS NULL",
|
||
sanitize_identifier(&table_name), sf, table_name, sf
|
||
));
|
||
}
|
||
}
|
||
|
||
// 覆盖索引(tenant_id + created_at,加速默认排序查询)
|
||
indexes.push(format!(
|
||
"CREATE INDEX IF NOT EXISTS \"idx_{}_tenant_cover\" ON \"{}\" (tenant_id, created_at DESC) INCLUDE (id, data, updated_at, version) WHERE deleted_at IS NULL",
|
||
sanitize_identifier(&table_name), table_name
|
||
));
|
||
|
||
let gen_cols_sql = if gen_cols.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!(",\n{}", gen_cols.join(",\n"))
|
||
};
|
||
|
||
format!(
|
||
"CREATE TABLE IF NOT EXISTS \"{}\" (\
|
||
\"id\" UUID PRIMARY KEY DEFAULT gen_random_uuid(), \
|
||
\"tenant_id\" UUID NOT NULL, \
|
||
\"data\" JSONB NOT NULL DEFAULT '{{}}'{gen_cols}, \
|
||
\"created_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
|
||
\"updated_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
|
||
\"created_by\" UUID, \
|
||
\"updated_by\" UUID, \
|
||
\"deleted_at\" TIMESTAMPTZ, \
|
||
\"version\" INT NOT NULL DEFAULT 1);\n\
|
||
{}",
|
||
table_name,
|
||
indexes.join(";\n"),
|
||
gen_cols = gen_cols_sql,
|
||
)
|
||
}
|
||
```
|
||
|
||
然后重构现有 `create_table` 方法调用新的 DDL 生成器:
|
||
|
||
```rust
|
||
/// 创建动态表(执行 DDL)
|
||
pub async fn create_table(
|
||
db: &DatabaseConnection,
|
||
plugin_id: &str,
|
||
entity: &PluginEntity,
|
||
) -> PluginResult<()> {
|
||
let ddl = Self::build_create_table_sql(plugin_id, entity);
|
||
|
||
// 分号分割,逐条执行
|
||
for sql in ddl.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||
tracing::info!(sql = %sql, "Executing DDL");
|
||
db.execute_unprepared(sql).await.map_err(|e| {
|
||
tracing::error!(sql = %sql, error = %e, "DDL execution failed");
|
||
PluginError::DatabaseError(e.to_string())
|
||
})?;
|
||
}
|
||
|
||
tracing::info!(plugin_id = %plugin_id, entity = %entity.name, "Dynamic table created with Generated Columns");
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
> **重要:** 删除原有的手动索引创建循环(原 `create_table` 中的 for field in &entity.fields 索引创建代码),全部由 `build_create_table_sql` 生成。
|
||
|
||
- [ ] **Step 4: 运行测试**
|
||
|
||
Run: `cargo test -p erp-plugin -- test_build_create_table`
|
||
Expected: PASS
|
||
|
||
Run: `cargo test -p erp-plugin` (全部测试)
|
||
Expected: 原有 15 个测试 + 新增 2 个测试全部 PASS
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-plugin/src/dynamic_table.rs
|
||
git commit -m "feat(plugin): create_table 使用 Generated Column + pg_trgm + 覆盖索引"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1.4: dynamic_table.rs — SQL 查询路由(Generated Column 优先)
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin/src/dynamic_table.rs`
|
||
|
||
当 Generated Column 存在时,查询中 `data->>'field'` 应替换为 `_f_field`,利用 B-tree 索引。
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```rust
|
||
#[test]
|
||
fn test_field_reference_uses_generated_column() {
|
||
// 当字段存在 Generated Column 时,SQL 应使用 _f_{name} 而非 data->>'{name}'
|
||
let generated_fields = vec!["code".to_string(), "status".to_string(), "level".to_string()];
|
||
let ref_fn = DynamicTableManager::field_reference_fn(&generated_fields);
|
||
|
||
assert_eq!(ref_fn("code"), "_f_code");
|
||
assert_eq!(ref_fn("status"), "_f_status");
|
||
assert_eq!(ref_fn("name"), "data->>'name'"); // 非 Generated Column
|
||
assert_eq!(ref_fn("remark"), "data->>'remark'"); // 非 Generated Column
|
||
}
|
||
|
||
#[test]
|
||
fn test_filtered_query_uses_generated_column_for_sort() {
|
||
let generated_fields = vec!["code".to_string(), "level".to_string()];
|
||
let (sql, _) = DynamicTableManager::build_filtered_query_sql_ex(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
20, 0, None, None,
|
||
Some("level".to_string()),
|
||
Some("asc".to_string()),
|
||
&generated_fields,
|
||
).unwrap();
|
||
|
||
assert!(
|
||
sql.contains("ORDER BY \"_f_level\" ASC"),
|
||
"排序应使用 Generated Column,got: {}", sql
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filtered_query_uses_generated_column_for_filter() {
|
||
let generated_fields = vec!["status".to_string()];
|
||
let (sql, _) = DynamicTableManager::build_filtered_query_sql_ex(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
20, 0,
|
||
Some(serde_json::json!({"status": "active"})),
|
||
None, None, None,
|
||
&generated_fields,
|
||
).unwrap();
|
||
|
||
assert!(
|
||
sql.contains("\"_f_status\" = $"),
|
||
"过滤应使用 Generated Column,got: {}", sql
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证失败**
|
||
|
||
Run: `cargo test -p erp-plugin -- field_reference`
|
||
Expected: 编译失败
|
||
|
||
- [ ] **Step 3: 实现字段引用路由**
|
||
|
||
在 `DynamicTableManager` 中添加:
|
||
|
||
```rust
|
||
/// 返回字段引用函数的闭包 — Generated Column 存在时用 _f_{name},否则用 data->>'{name}'
|
||
pub fn field_reference_fn(generated_fields: &[String]) -> impl Fn(&str) -> String + '_ {
|
||
move |field_name: &str| {
|
||
let clean = sanitize_identifier(field_name);
|
||
if generated_fields.contains(&clean) {
|
||
format!("\"_f_{}\"", clean)
|
||
} else {
|
||
format!("\"data\"->>'{}'", clean)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 扩展版查询构建 — 支持 Generated Column 路由
|
||
pub fn build_filtered_query_sql_ex(
|
||
table_name: &str,
|
||
tenant_id: Uuid,
|
||
limit: u64,
|
||
offset: u64,
|
||
filter: Option<serde_json::Value>,
|
||
search: Option<(String, String)>,
|
||
sort_by: Option<String>,
|
||
sort_order: Option<String>,
|
||
generated_fields: &[String],
|
||
) -> Result<(String, Vec<Value>), String> {
|
||
let ref_fn = Self::field_reference_fn(generated_fields);
|
||
|
||
let mut conditions = vec![
|
||
format!("\"tenant_id\" = ${}", 1),
|
||
"\"deleted_at\" IS NULL".to_string(),
|
||
];
|
||
let mut param_idx = 2;
|
||
let mut values: Vec<Value> = vec![tenant_id.into()];
|
||
|
||
// filter
|
||
if let Some(f) = filter {
|
||
if let Some(obj) = f.as_object() {
|
||
for (key, val) in obj {
|
||
let clean_key = sanitize_identifier(key);
|
||
if clean_key.is_empty() {
|
||
return Err(format!("无效的过滤字段名: {}", key));
|
||
}
|
||
conditions.push(format!("{} = ${}", ref_fn(&clean_key), param_idx));
|
||
values.push(Value::String(Some(Box::new(
|
||
val.as_str().unwrap_or("").to_string(),
|
||
))));
|
||
param_idx += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// search(searchable 字段仍在 JSONB 中,使用 pg_trgm 索引)
|
||
if let Some((fields_csv, keyword)) = search {
|
||
let escaped = keyword.replace('%', "\\%").replace('_', "\\_");
|
||
let fields: Vec<&str> = fields_csv.split(',').collect();
|
||
let search_param_idx = param_idx;
|
||
let search_conditions: Vec<String> = fields
|
||
.iter()
|
||
.map(|f| {
|
||
let clean = sanitize_identifier(f.trim());
|
||
format!("\"data\"->>'{}' ILIKE ${}", clean, search_param_idx)
|
||
})
|
||
.collect();
|
||
conditions.push(format!("({})", search_conditions.join(" OR ")));
|
||
values.push(Value::String(Some(Box::new(format!("%{}%", escaped)))));
|
||
param_idx += 1;
|
||
}
|
||
|
||
// sort
|
||
let order_clause = if let Some(sb) = sort_by {
|
||
let clean = sanitize_identifier(&sb);
|
||
if clean.is_empty() {
|
||
return Err(format!("无效的排序字段名: {}", sb));
|
||
}
|
||
let dir = match sort_order.as_deref() {
|
||
Some("asc") | Some("ASC") => "ASC",
|
||
_ => "DESC",
|
||
};
|
||
format!("ORDER BY {} {}", ref_fn(&clean), dir)
|
||
} else {
|
||
"ORDER BY \"created_at\" DESC".to_string()
|
||
};
|
||
|
||
let sql = format!(
|
||
"SELECT id, data, created_at, updated_at, version FROM \"{}\" WHERE {} {} LIMIT ${} OFFSET ${}",
|
||
table_name,
|
||
conditions.join(" AND "),
|
||
order_clause,
|
||
param_idx,
|
||
param_idx + 1,
|
||
);
|
||
values.push((limit as i64).into());
|
||
values.push((offset as i64).into());
|
||
|
||
Ok((sql, values))
|
||
}
|
||
```
|
||
|
||
> 保持原有 `build_filtered_query_sql` 不变(无 generated_fields 参数版本,向后兼容),新方法 `_ex` 后缀。后续 Task 由 data_service 层传入 generated_fields。
|
||
|
||
- [ ] **Step 4: 运行测试**
|
||
|
||
Run: `cargo test -p erp-plugin`
|
||
Expected: 全部 PASS
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```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?;
|
||
|
||
// 生成下一页 cursor(W2 修复:编码排序字段值)
|
||
let next_cursor = rows.last().map(|r| {
|
||
let sort_val = if let Some(ref sb) = sort_by {
|
||
r.data.get(sb).and_then(|v| v.as_str()).unwrap_or("").to_string()
|
||
} else {
|
||
// 默认排序用 created_at 的字符串表示
|
||
r.created_at.to_rfc3339()
|
||
};
|
||
DynamicTableManager::encode_cursor(&[sort_val], &r.id)
|
||
});
|
||
|
||
let items = rows.into_iter().map(|r| PluginDataResp {
|
||
id: r.id.to_string(),
|
||
data: r.data,
|
||
created_at: Some(r.created_at),
|
||
updated_at: Some(r.updated_at),
|
||
version: Some(r.version),
|
||
}).collect();
|
||
|
||
Ok((items, total, next_cursor))
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 更新 handler 调用**
|
||
|
||
`data_handler.rs` 的 `list_plugin_data` 需要传入 `cache` 和 `cursor`,在后续 Chunk 中统一调整。
|
||
|
||
- [ ] **Step 3: 编译验证**
|
||
|
||
Run: `cargo check -p erp-plugin`
|
||
Expected: 编译通过
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-plugin/src/data_service.rs
|
||
git commit -m "feat(plugin): list 方法集成 Generated Column 路由 + Keyset Pagination"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1.9: CRM plugin.toml — 更新声明以利用 Generated Column
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin-crm/plugin.toml`
|
||
|
||
- [ ] **Step 1: 确认当前字段声明已包含必要标记**
|
||
|
||
CRM 的 `plugin.toml` 已正确声明了 `unique`, `filterable`, `sortable`, `searchable` 标记。`create_table` 方法会自动根据这些标记创建 Generated Column 和索引。**无需修改 plugin.toml。**
|
||
|
||
验证:重新安装 CRM 插件后,`plugin_erp_crm_customer` 表应自动包含 `_f_code`, `_f_name`, `_f_customer_type`, `_f_status`, `_f_level` 等 Generated Column。
|
||
|
||
- [ ] **Step 2: 集成验证**
|
||
|
||
Run: 启动后端服务 `cargo run -p erp-server`,调用 API 卸载+重新安装 CRM 插件
|
||
Expected: 动态表包含 Generated Column,pg_trgm 索引已创建
|
||
|
||
- [ ] **Step 3: 提交(如有改动)**
|
||
|
||
```bash
|
||
git add crates/erp-plugin-crm/plugin.toml
|
||
git commit -m "chore(crm): 验证 Generated Column 自动生成 — 无需修改 plugin.toml"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 2: 数据完整性框架
|
||
|
||
### Task 2.1: manifest.rs — 扩展 PluginField (ref_entity / validation / no_cycle)
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin/src/manifest.rs:49-69` (PluginField struct)
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```rust
|
||
#[test]
|
||
fn parse_field_with_ref_entity() {
|
||
let toml = r#"
|
||
[metadata]
|
||
id = "test"
|
||
name = "Test"
|
||
version = "0.1.0"
|
||
|
||
[schema]
|
||
[[schema.entities]]
|
||
name = "contact"
|
||
display_name = "联系人"
|
||
|
||
[[schema.entities.fields]]
|
||
name = "customer_id"
|
||
field_type = "uuid"
|
||
required = true
|
||
display_name = "所属客户"
|
||
ref_entity = "customer"
|
||
"#;
|
||
let manifest = parse_manifest(toml).unwrap();
|
||
let field = &manifest.schema.unwrap().entities[0].fields[0];
|
||
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_field_with_validation() {
|
||
let toml = r#"
|
||
[metadata]
|
||
id = "test"
|
||
name = "Test"
|
||
version = "0.1.0"
|
||
|
||
[schema]
|
||
[[schema.entities]]
|
||
name = "contact"
|
||
display_name = "联系人"
|
||
|
||
[[schema.entities.fields]]
|
||
name = "phone"
|
||
field_type = "string"
|
||
display_name = "手机号"
|
||
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
|
||
"#;
|
||
let manifest = parse_manifest(toml).unwrap();
|
||
let field = &manifest.schema.unwrap().entities[0].fields[0];
|
||
let v = field.validation.as_ref().unwrap();
|
||
assert_eq!(v.pattern.as_deref(), Some("^1[3-9]\\d{9}$"));
|
||
assert_eq!(v.message.as_deref(), Some("手机号格式不正确"));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_field_with_no_cycle() {
|
||
let toml = r#"
|
||
[metadata]
|
||
id = "test"
|
||
name = "Test"
|
||
version = "0.1.0"
|
||
|
||
[schema]
|
||
[[schema.entities]]
|
||
name = "customer"
|
||
display_name = "客户"
|
||
|
||
[[schema.entities.fields]]
|
||
name = "parent_id"
|
||
field_type = "uuid"
|
||
display_name = "上级客户"
|
||
ref_entity = "customer"
|
||
no_cycle = true
|
||
"#;
|
||
let manifest = parse_manifest(toml).unwrap();
|
||
let field = &manifest.schema.unwrap().entities[0].fields[0];
|
||
assert_eq!(field.no_cycle, Some(true));
|
||
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证测试失败**
|
||
|
||
Run: `cargo test -p erp-plugin -- parse_field_with_ref`
|
||
Expected: 编译失败(`ref_entity` 字段不存在)
|
||
|
||
- [ ] **Step 3: 扩展 PluginField 结构体**
|
||
|
||
在 `PluginField` 中新增三个字段:
|
||
|
||
```rust
|
||
pub struct PluginField {
|
||
// ... 已有字段 ...
|
||
pub ref_entity: Option<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(¤t_id) {
|
||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||
return Err(AppError::Validation(format!(
|
||
"字段 '{}' 形成循环引用", label
|
||
)));
|
||
}
|
||
visited.push(current_id);
|
||
|
||
// 查询 current 的 parent_id
|
||
let query_sql = format!(
|
||
"SELECT data->>'{}' as parent FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||
field_name, table_name
|
||
);
|
||
#[derive(FromQueryResult)]
|
||
struct ParentRow { parent: Option<String> }
|
||
let row = ParentRow::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
query_sql,
|
||
[current_id.into(), tenant_id.into()],
|
||
)).one(db).await?;
|
||
|
||
match row {
|
||
Some(r) => {
|
||
let parent = r.parent.unwrap_or_default().trim().to_string();
|
||
if parent.is_empty() {
|
||
break; // 到达根节点
|
||
}
|
||
current_id = Uuid::parse_str(&parent).map_err(|_| {
|
||
AppError::Internal("parent_id 不是有效的 UUID".to_string())
|
||
})?;
|
||
}
|
||
None => break, // 记录不存在或已删除
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 集成到 update 方法**
|
||
|
||
在 `update` 方法中,`validate_ref_entities` 之后添加:
|
||
|
||
```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 插件基座升级 — 集成验证通过"
|
||
```
|