docs(spec): 新增 CRM 插件基座升级设计规格 v1.1
6 专家组深度评审后的基座优先改进方案: - JSONB 存储优化 (Generated Column + pg_trgm + Keyset Pagination) - 数据完整性框架 (ref_entity + 级联策略 + 字段校验 + 循环引用检测) - 行级数据权限 (self/department/department_tree/all 四级) - 前端页面能力增强 (entity_select + kanban + 批量操作 + Dashboard 图表)
This commit is contained in:
@@ -0,0 +1,767 @@
|
||||
# CRM 插件基座升级设计规格 v1.0
|
||||
|
||||
> **文档状态:** v1.1 — 已修复评审问题
|
||||
> **创建日期:** 2026-04-17
|
||||
> **范围:** JSONB 存储优化 + 数据完整性框架 + 行级数据权限 + 前端页面能力增强
|
||||
> **评审记录:** code-reviewer 子代理评审通过一轮修复(3 Critical + 7 Important)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
CRM 插件是 ERP 平台的第一个 WASM 行业插件,已完成 3 阶段 24 任务,包含 5 实体、9 权限、7 页面类型。经 6 个专家组深度评审,发现以下结构性问题需要优先解决:
|
||||
|
||||
| 问题 | 严重级别 | 影响 |
|
||||
|------|---------|------|
|
||||
| JSONB 动态表类型安全缺失、排序全表扫描 | High | 万级数据以上性能崩溃 |
|
||||
| JSONB 零外键完整性、零级联策略 | High | 数据"脏"掉,引用断裂 |
|
||||
| 行级数据权限缺失 | Critical | 销售A能看到销售B的所有客户 |
|
||||
| plugin.admin 权限 fallback 过宽 | Critical | 超级用户权限泄露 |
|
||||
| 无关联选择器 (entity_select) | High | UX 极差,客户ID手动输入 |
|
||||
| 无看板/批量操作/图表等页面能力 | Medium | CRM 功能不完整 |
|
||||
|
||||
**核心原则:** 基座优先。所有改进沉淀为插件平台通用能力,CRM 作为第一受益者而非唯一受益者。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
1. **JSONB 存储优化** — 百万级数据下列表查询 p95 < 200ms,搜索 p95 < 300ms
|
||||
2. **数据完整性框架** — 应用层外键校验、级联策略、字段校验、循环引用检测
|
||||
3. **行级数据权限** — 支持 self/department/department_tree/all 四级数据范围
|
||||
4. **前端页面能力增强** — 关联选择器、看板页面、批量操作、Dashboard 图表、visible_when 增强
|
||||
|
||||
---
|
||||
|
||||
## 3. JSONB 存储优化
|
||||
|
||||
### 3.1 Generated Column 混合存储
|
||||
|
||||
利用 PostgreSQL 12+ 的 `GENERATED ALWAYS AS ... STORED` 列,自动从 JSONB `data` 列提取高频字段到独立列。数据只存一份(在 JSONB 中),Generated Column 是自动派生的,零维护成本。
|
||||
|
||||
**提取规则(在 `dynamic_table.rs` 的 `create_table` 中自动判断):**
|
||||
|
||||
| 字段特征 | 提取策略 | 原因 |
|
||||
|----------|---------|------|
|
||||
| `unique == true` | Generated Column + UNIQUE INDEX | 需要精确唯一性约束 |
|
||||
| `required == true && (sortable \|\| filterable)` | Generated Column + INDEX | 需要类型化排序/筛选 |
|
||||
| `sortable == true` | Generated Column + INDEX | ORDER BY 走 B-tree |
|
||||
| `filterable == true` | Generated Column + INDEX | WHERE 走索引扫描 |
|
||||
| `searchable == true` | 保留 JSONB + pg_trgm GIN 索引 | 模糊搜索用三元组索引 |
|
||||
| 其他字段 | 保留 JSONB | 无需索引 |
|
||||
|
||||
**生成的 DDL 示例:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE plugin_erp_crm_customer (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
-- Generated Columns
|
||||
_f_code TEXT GENERATED ALWAYS AS (data->>'code') STORED,
|
||||
_f_name TEXT GENERATED ALWAYS AS (data->>'name') STORED,
|
||||
_f_customer_type TEXT GENERATED ALWAYS AS (data->>'customer_type') STORED,
|
||||
_f_status TEXT GENERATED ALWAYS AS (data->>'status') STORED,
|
||||
_f_level TEXT GENERATED ALWAYS AS (data->>'level') STORED,
|
||||
-- 标准字段
|
||||
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
|
||||
);
|
||||
|
||||
-- 复合索引(tenant_id 在前,支持多租户过滤)
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_tenant_cover
|
||||
ON "{t}" (tenant_id, created_at DESC)
|
||||
INCLUDE (id, data, updated_at, version)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_f_name_sort
|
||||
ON "{t}" (tenant_id, _f_name)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_{t}_f_code_uniq
|
||||
ON "{t}" (tenant_id, _f_code)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_f_type_filter
|
||||
ON "{t}" (tenant_id, _f_customer_type)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**SQL 查询路由:** 在 `dynamic_table.rs` 中新增 `GeneratedColumnInfo` 结构,记录哪些字段被提取为 Generated Column。`build_filtered_query_sql` 和 `build_aggregate_sql` 检测到对应 Generated Column 存在时,自动将 `data->>'field'` 替换为 `_f_{field}`。
|
||||
|
||||
**类型映射:** `data->>'field'` 始终返回 TEXT。对于非字符串类型,Generated Column 需要类型转换以支持正确的排序和比较:
|
||||
|
||||
| field_type | SQL 类型 | Generated Column 表达式 |
|
||||
|------------|---------|------------------------|
|
||||
| String | TEXT | `data->>'field'` |
|
||||
| Integer | INTEGER | `(data->>'field')::INTEGER` |
|
||||
| Float | DOUBLE PRECISION | `(data->>'field')::DOUBLE PRECISION` |
|
||||
| Decimal | NUMERIC(18,4) | `(data->>'field')::NUMERIC` |
|
||||
| Boolean | BOOLEAN | `(data->>'field')::BOOLEAN` |
|
||||
| Date | DATE | `(data->>'field')::DATE` |
|
||||
| DateTime | TIMESTAMPTZ | `(data->>'field')::TIMESTAMPTZ` |
|
||||
| Uuid | UUID | `(data->>'field')::UUID` |
|
||||
|
||||
`dynamic_table.rs` 的 `create_table` 根据 `PluginField.field_type` 自动选择正确的 SQL 类型和类型转换表达式。
|
||||
|
||||
**元数据表:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS plugin_entity_columns (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL, -- 多租户标准字段
|
||||
plugin_entity_id UUID NOT NULL REFERENCES plugin_entities(id),
|
||||
field_name VARCHAR(100) NOT NULL,
|
||||
column_name VARCHAR(100) NOT NULL, -- 如 _f_name
|
||||
sql_type VARCHAR(50) NOT NULL, -- 如 TEXT, INTEGER, UUID
|
||||
is_generated BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Schema 演变策略(重新安装/字段变更):**
|
||||
|
||||
当前 `service.rs` 的 `install` 使用 `CREATE TABLE IF NOT EXISTS`。引入 Generated Column 后,安装流程改为:
|
||||
|
||||
1. **首次安装**:`CREATE TABLE` 包含所有 Generated Column。
|
||||
2. **重新安装(同版本)**:`IF NOT EXISTS` 跳过表创建。比对 `plugin_entity_columns` 元数据与当前 manifest 的字段列表,执行增量 ALTER:
|
||||
- 新增字段:`ALTER TABLE ADD COLUMN _f_{name} {type} GENERATED ALWAYS AS (...) STORED`
|
||||
- 删除字段:`ALTER TABLE DROP COLUMN _f_{name}`(仅删除 Generated Column,JSONB data 中的原始值保留)
|
||||
- 类型变更:PostgreSQL 不支持 ALTER GENERATED COLUMN 的表达式,需 DROP + ADD
|
||||
3. **插件卸载时**:表被删除,元数据自动清理。
|
||||
|
||||
`dynamic_table.rs` 新增 `migrate_table` 方法,接受已有列列表和目标列列表,生成增量 DDL。
|
||||
|
||||
### 3.2 pg_trgm 模糊搜索加速
|
||||
|
||||
**迁移文件:** 在 `erp-server/migration` 中新增迁移启用 pg_trgm 扩展:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
```
|
||||
|
||||
**索引创建:** `create_table` 中 searchable 字段的索引从普通 B-tree 改为 GIN 三元组:
|
||||
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_{f}_trgm
|
||||
ON "{t}" USING GIN ((data->>'{f}') gin_trgm_ops)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
启用后 `ILIKE '%keyword%'` 从全表扫描退化为索引扫描,百万级数据搜索从 2-5s 降至 50-200ms。
|
||||
|
||||
### 3.3 Keyset Pagination
|
||||
|
||||
**向后兼容设计:** API 同时支持 OFFSET 和 cursor 两种分页模式。
|
||||
|
||||
`data_dto.rs` 中 `PluginDataListParams` 新增 `cursor` 字段:
|
||||
|
||||
```rust
|
||||
pub struct PluginDataListParams {
|
||||
pub page: Option<u64>, // 保留,向后兼容
|
||||
pub page_size: Option<u64>,
|
||||
pub cursor: Option<String>, // 新增:Base64 编码的游标
|
||||
pub search: Option<String>,
|
||||
pub filter: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
`dynamic_table.rs` 中 SQL 构建逻辑:当 `cursor` 存在时使用 keyset 分页:
|
||||
|
||||
**游标编码格式:** JSON 结构 `{ "v": [value1, value2, ...], "id": "uuid" }`,Base64 编码。`v` 数组存储排序字段的值(与 sort_by 顺序一致),`id` 是记录主键作为最终 tiebreaker。多列排序时 `v` 包含多个值。字段值为 null 时存储 JSON null。
|
||||
|
||||
客户端必须在每次请求中同时发送 `cursor` 和 `sort_by`/`sort_order`(游标不嵌入排序信息,保持无状态)。
|
||||
|
||||
```sql
|
||||
-- 第一页
|
||||
SELECT ... ORDER BY _f_name ASC, id ASC LIMIT 20;
|
||||
|
||||
-- 后续页(cursor 解码后)
|
||||
SELECT ... WHERE (_f_name, id) > ($cursor_sort_val, $cursor_id)
|
||||
ORDER BY _f_name ASC, id ASC LIMIT 20;
|
||||
```
|
||||
|
||||
### 3.4 Schema 缓存
|
||||
|
||||
在 `PluginState` 中添加 `moka` LRU 缓存,消除每次数据请求的 `resolve_entity_info` 查库:
|
||||
|
||||
```rust
|
||||
pub entity_cache: Cache<String, EntityInfo>, // key: "{plugin_id}:{entity_name}:{tenant_id}"
|
||||
```
|
||||
|
||||
TTL 5 分钟,容量 1000 条。
|
||||
|
||||
### 3.5 聚合 Redis 缓存
|
||||
|
||||
`data_service.rs` 的 create/update/delete 成功后增量更新 Redis 统计:
|
||||
|
||||
```
|
||||
plugin:{plugin_id}:{entity}:count:{tenant_id} → 计数值
|
||||
plugin:{plugin_id}:{entity}:agg:{field}:{tenant_id} → JSON {key: count}
|
||||
```
|
||||
|
||||
Dashboard 查询直接从 Redis 读取,TTL 5 分钟兜底。
|
||||
|
||||
### 3.6 性能 SLA 目标
|
||||
|
||||
**测试条件:** PostgreSQL 与应用同机部署(Redis localhost 延迟 < 1ms)。SLA 包含 Redis 往返(schema 缓存 + 部门缓存)。冷启动(Redis 缓存未命中)首次查询允许 3x SLA 宽限。
|
||||
|
||||
| 查询场景 | 数据量 | p50 | p95 | p99 |
|
||||
|----------|--------|-----|-----|-----|
|
||||
| 按 ID 获取单条 | 100万 | < 5ms | < 10ms | < 20ms |
|
||||
| 列表查询(默认排序) | 100万 | < 20ms | < 50ms | < 100ms |
|
||||
| 列表查询(字段排序) | 100万 | < 30ms | < 100ms | < 200ms |
|
||||
| 搜索(ILIKE) | 100万 | < 50ms | < 100ms | < 300ms |
|
||||
| 聚合查询 | 100万 | < 50ms (缓存) | < 500ms (实时) | - |
|
||||
| Dashboard 全量加载 | 100万 | < 200ms | < 500ms | - |
|
||||
|
||||
### 3.7 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 主要改动 — Generated Column DDL、索引策略、SQL 路由、keyset 分页 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 缓存逻辑、聚合 Redis 缓存 |
|
||||
| `crates/erp-plugin/src/data_dto.rs` | 新增 cursor 参数 |
|
||||
| `crates/erp-plugin/src/state.rs` | 新增 entity_cache |
|
||||
| `crates/erp-plugin/src/manifest.rs` | PluginEntityColumns 元数据 |
|
||||
| `crates/erp-server/migration/src/` | pg_trgm 扩展 + plugin_entity_columns 表 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据完整性框架
|
||||
|
||||
### 4.1 外键引用声明
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `ref_entity` 字段:
|
||||
|
||||
```rust
|
||||
pub struct PluginField {
|
||||
pub name: String,
|
||||
pub field_type: PluginFieldType,
|
||||
// ...已有字段...
|
||||
pub ref_entity: Option<String>, // 新增:引用的实体名
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "所属客户"
|
||||
ref_entity = "customer" # 声明外键引用
|
||||
```
|
||||
|
||||
### 4.2 应用层外键校验
|
||||
|
||||
在 `data_service.rs` 的 `validate_data` 函数中扩展:
|
||||
|
||||
```
|
||||
create/update 时:
|
||||
遍历 fields,如果 field.ref_entity 存在:
|
||||
1. 从 data 中取出该字段的 UUID 值
|
||||
2. 如果值为 null 或空字符串且 required == false → 跳过校验
|
||||
3. 如果是自引用(ref_entity == 当前实体名)且为 create 操作:
|
||||
a. 如果引用的是自身 ID → 跳过(记录尚不存在,无法校验)
|
||||
b. 如果引用的是其他记录 → 正常校验
|
||||
4. 查询 ref_entity 对应的动态表,验证该记录存在且未删除
|
||||
5. 不存在则返回 ValidationError
|
||||
|
||||
TOCTOU 竞态说明:
|
||||
外键校验与引用记录删除之间存在理论上的竞态窗口。
|
||||
对于 JSONB 动态表,这是可接受的风险——应用层校验已大幅降低孤立引用概率。
|
||||
如果未来需要严格保证,可在 flush_ops 中增加二次校验(事务内 SELECT FOR UPDATE)。
|
||||
```
|
||||
|
||||
### 4.3 级联删除策略
|
||||
|
||||
`manifest.rs` 新增 `PluginRelation` 结构:
|
||||
|
||||
```rust
|
||||
pub struct PluginRelation {
|
||||
pub entity: String, // 关联实体名
|
||||
pub foreign_key: String, // 关联实体中的外键字段名
|
||||
pub on_delete: OnDeleteStrategy, // 级联策略
|
||||
}
|
||||
|
||||
pub enum OnDeleteStrategy {
|
||||
Nullify, // 置空外键字段
|
||||
Cascade, // 级联软删除
|
||||
Restrict, // 存在关联时拒绝删除
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[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"
|
||||
```
|
||||
|
||||
`data_service.rs` 的 `delete` 方法中,在软删除记录之前:
|
||||
|
||||
```
|
||||
1. 从 manifest 中查找该实体声明的所有 relations
|
||||
2. 对每个 relation:
|
||||
- Restrict: 查询关联实体是否有引用 → 有则拒绝删除
|
||||
- Nullify: 批量 UPDATE 关联记录,将 foreign_key 设为 null
|
||||
- Cascade: 批量软删除关联记录(级联深度上限 3 层,防止 A→B→C→D 无限递归)
|
||||
```
|
||||
|
||||
### 4.4 字段校验规则
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `validation` 子结构:
|
||||
|
||||
```rust
|
||||
pub struct FieldValidation {
|
||||
pub pattern: Option<String>, // 正则表达式
|
||||
pub message: Option<String>, // 校验失败提示
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```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 = "邮箱格式不正确" }
|
||||
```
|
||||
|
||||
`validate_data` 扩展:对有 `validation.pattern` 的字段,使用 `regex` crate 做正则匹配。
|
||||
|
||||
### 4.5 循环引用检测
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `no_cycle` 字段:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "parent_id"
|
||||
field_type = "uuid"
|
||||
ref_entity = "customer"
|
||||
no_cycle = true # 声明不允许循环引用
|
||||
```
|
||||
|
||||
`data_service.rs` 的 `update` 方法中,当 `no_cycle == true` 的字段被修改时:
|
||||
|
||||
```
|
||||
1. 从 data 中取出新值 (new_parent_id)
|
||||
2. 初始化 visited = {record_id}
|
||||
3. 循环:查询 current 的 parent_id → 如果在 visited 中则报错 → 加入 visited
|
||||
4. 直到 parent_id 为 null 或到达根节点
|
||||
```
|
||||
|
||||
### 4.6 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/manifest.rs` | 新增 ref_entity / PluginRelation / FieldValidation / no_cycle |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 外键校验 / 级联删除 / 字段校验 / 循环检测 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | 为现有字段添加 ref_entity / relations / validation / no_cycle 声明 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 行级数据权限
|
||||
|
||||
### 5.1 数据范围模型
|
||||
|
||||
在实体级别声明是否启用行级数据权限,在权限级别声明数据范围等级。
|
||||
|
||||
**manifest 扩展:**
|
||||
|
||||
```toml
|
||||
[[schema.entities]]
|
||||
name = "customer"
|
||||
display_name = "客户"
|
||||
data_scope = true # 启用行级数据权限
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "owner_id"
|
||||
field_type = "uuid"
|
||||
display_name = "负责人"
|
||||
scope_role = "owner" # 标记为数据权限的"所有者"字段
|
||||
```
|
||||
|
||||
**权限声明扩展:**
|
||||
|
||||
```toml
|
||||
[[permissions]]
|
||||
code = "customer.list"
|
||||
name = "查看客户"
|
||||
data_scope_levels = ["self", "department", "department_tree", "all"]
|
||||
```
|
||||
|
||||
### 5.2 数据范围等级定义
|
||||
|
||||
| 等级 | 含义 | SQL 条件 |
|
||||
|------|------|---------|
|
||||
| `self` | 只看自己负责/创建的 | `data->>'owner_id' = current_user_id OR created_by = current_user_id` |
|
||||
| `department` | 看本部门所有人的 | `data->>'owner_id' IN (部门用户列表)` |
|
||||
| `department_tree` | 看本部门及下级部门 | `data->>'owner_id' IN (部门树用户列表)` |
|
||||
| `all` | 看全部 | 无额外条件 |
|
||||
|
||||
### 5.3 实现路径
|
||||
|
||||
**TenantContext 扩展:** `erp-core` 的 `TenantContext` 结构新增 `department_ids: Vec<Uuid>` 字段(注意:用户可通过岗位属于多个部门)。JWT claims 中新增 `dept_ids` 字段,JWT 中间件在构造 TenantContext 时填充。
|
||||
|
||||
**多部门用户处理:** 用户通过 Position 关联到多个 Department。`department` 级别取所有所属部门的并集;`department_tree` 取所有所属部门及其下级部门的并集。没有岗位/部门的用户在 `department` 和 `department_tree` 级别下只能看到自己创建的数据(降级为 self)。
|
||||
|
||||
**角色权限表扩展:** `role_permissions` 表新增 `data_scope` 字段(VARCHAR(32),默认值 `'all'`)。新增迁移文件 `m20260418_*_add_data_scope_to_role_permissions.rs`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE role_permissions ADD COLUMN IF NOT EXISTS data_scope VARCHAR(32) NOT NULL DEFAULT 'all';
|
||||
```
|
||||
|
||||
**管理界面适配:** 角色权限分配界面新增"数据范围"下拉选项,管理员为每个权限分配时选择 self/department/department_tree/all。
|
||||
|
||||
**查询注入:** `data_service.rs` 的 `list` / `count` / `aggregate` 方法中:
|
||||
|
||||
```
|
||||
1. 从权限检查结果中获取该权限对应的 data_scope 等级
|
||||
2. 如果实体启用了 data_scope:
|
||||
- self: 注入 owner_id / created_by 过滤条件
|
||||
- department: 查询用户所在部门的所有用户 ID,注入 IN 条件
|
||||
- department_tree: 递归查询部门树,注入 IN 条件
|
||||
- all: 无额外条件
|
||||
3. 将条件追加到 dynamic_table 的 SQL 构建中
|
||||
```
|
||||
|
||||
**部门用户缓存:** 使用 Redis 缓存部门-用户映射关系,TTL 10 分钟,避免每次查询都递归查部门树。当部门分配变更时通过 EventBus 事件 (`department.member_changed`) 失效缓存。
|
||||
|
||||
### 5.4 权限 fallback 收紧
|
||||
|
||||
**当前行为(危险):** `data_handler.rs` 中,如果没有实体级权限,fallback 到 `plugin.admin`,获得所有数据访问权。
|
||||
|
||||
**修改后:** 移除 fallback 逻辑。权限检查链改为:
|
||||
|
||||
```
|
||||
1. 检查实体级权限 ({manifest_id}.{entity}.{action})
|
||||
2. 存在 → 通过,附带 data_scope
|
||||
3. 不存在 → 拒绝 (403)
|
||||
```
|
||||
|
||||
`plugin.admin` 只管理插件生命周期(上传/安装/启用/禁用/卸载),不自动获得数据访问权。需要显式分配实体级权限。
|
||||
|
||||
**迁移策略(避免现有管理员失去访问):** 在收紧 fallback 的迁移中,同时执行以下补偿:
|
||||
|
||||
```sql
|
||||
-- 为所有拥有 plugin.admin 权限的角色,自动分配所有已安装插件的实体级权限
|
||||
-- data_scope 默认设为 'all'(管理员级别)
|
||||
INSERT INTO role_permissions (id, role_id, permission_id, tenant_id, data_scope, ...)
|
||||
SELECT gen_random_uuid(), rp.role_id, p.id, rp.tenant_id, 'all', ...
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.tenant_id = rp.tenant_id
|
||||
WHERE rp.permission_id = (SELECT id FROM permissions WHERE code = 'plugin.admin')
|
||||
AND 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
|
||||
);
|
||||
```
|
||||
|
||||
这确保现有管理员在 fallback 收紧后仍保持完整的数据访问能力。
|
||||
|
||||
### 5.5 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-core/src/types.rs` | TenantContext 新增 department_ids 字段 |
|
||||
| `crates/erp-auth/src/middleware/jwt_auth.rs` | JWT claims 解析 department_ids |
|
||||
| `crates/erp-plugin/src/manifest.rs` | data_scope / scope_role / data_scope_levels |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 查询条件注入 |
|
||||
| `crates/erp-plugin/src/handler/data_handler.rs` | 移除权限 fallback |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | SQL 构建支持数据范围条件 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | customer 实体添加 data_scope / owner_id |
|
||||
| `crates/erp-server/migration/src/` | 新增 data_scope 列 + 权限补偿迁移 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端页面能力增强
|
||||
|
||||
### 6.1 关联选择器 (entity_select)
|
||||
|
||||
**Schema 扩展:** `PluginFieldSchema` 新增字段:
|
||||
|
||||
```typescript
|
||||
interface PluginFieldSchema {
|
||||
// ...已有字段...
|
||||
ref_entity?: string; // 引用的实体名
|
||||
ref_label_field?: string; // 显示字段
|
||||
ref_search_fields?: string[]; // 搜索字段
|
||||
cascade_from?: string; // 级联过滤来源字段
|
||||
cascade_filter?: string; // 级联过滤目标字段
|
||||
}
|
||||
```
|
||||
|
||||
**新增组件:** `EntitySelect.tsx` — 通用远程搜索选择器
|
||||
|
||||
```
|
||||
Props: pluginId, entity, labelField, searchFields, cascadeFrom?, cascadeFilter?, value?, onChange?
|
||||
内部: listPluginData(pluginId, entity, {search, filter}) → Ant Design Select + showSearch
|
||||
```
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
display_name = "所属客户"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "customer"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name", "code"]
|
||||
|
||||
[[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"
|
||||
```
|
||||
|
||||
### 6.2 Kanban 看板页面
|
||||
|
||||
**Schema 扩展:** `PluginPageType` 新增 `Kanban` 变体。
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```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 = ["name", "code", "region", "status"]
|
||||
enable_drag = true
|
||||
```
|
||||
|
||||
**新增组件:** `PluginKanbanPage.tsx`
|
||||
|
||||
- 使用 `@dnd-kit/core` + `@dnd-kit/sortable` 实现跨列拖拽
|
||||
- 每列使用 Ant Design Card 渲染卡片
|
||||
- 每列内支持虚拟滚动(节点数 > 50 时)
|
||||
- 拖拽结束调用 `PATCH /plugins/{id}/{entity}/{recordId}` 更新 lane_field 值
|
||||
|
||||
**后端新增:** `PATCH` 部分更新端点(当前只有 PUT 全量更新):
|
||||
|
||||
```
|
||||
PATCH /api/v1/plugins/{plugin_id}/{entity}/{id}
|
||||
Body: { "data": { "level": "vip" }, "version": 3 }
|
||||
```
|
||||
|
||||
与 PUT 的区别:PATCH 只更新 data 中提供的字段,未提供的字段保持不变。
|
||||
|
||||
### 6.3 批量操作
|
||||
|
||||
**CRUD 页面增强:** `PluginCRUDPage.tsx` 新增 `rowSelection` 和批量操作栏。
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "customer"
|
||||
enable_batch = true
|
||||
|
||||
[[ui.pages.batch_actions]]
|
||||
label = "批量删除"
|
||||
action = "batch_delete"
|
||||
permission = "customer.manage"
|
||||
confirm = true
|
||||
|
||||
[[ui.pages.batch_actions]]
|
||||
label = "批量修改状态"
|
||||
action = "batch_update"
|
||||
update_field = "status"
|
||||
permission = "customer.manage"
|
||||
```
|
||||
|
||||
**后端新增:** `POST /api/v1/plugins/{id}/{entity}/batch`
|
||||
|
||||
```rust
|
||||
pub enum BatchAction {
|
||||
BatchDelete { ids: Vec<Uuid> },
|
||||
BatchUpdate { ids: Vec<Uuid>, data: serde_json::Value },
|
||||
}
|
||||
```
|
||||
|
||||
批量操作在单个事务中执行,有上限(默认 100 条)。
|
||||
|
||||
### 6.4 visible_when 表达式增强
|
||||
|
||||
**当前:** 只支持 `field == 'value'` 单一等式。
|
||||
|
||||
**增强后支持:**
|
||||
|
||||
```toml
|
||||
visible_when = "customer_type == 'enterprise'"
|
||||
visible_when = "customer_type == 'enterprise' AND level == 'vip'"
|
||||
visible_when = "status == 'active' OR status == 'pending'"
|
||||
visible_when = "NOT status == 'blacklist'"
|
||||
visible_when = "customer_type == 'enterprise' AND (level == 'vip' OR level == 'svip')"
|
||||
```
|
||||
|
||||
**前端实现:** 新建 `exprEvaluator.ts`,约 100 行递归下降表达式解析器:
|
||||
|
||||
```typescript
|
||||
interface ExprNode {
|
||||
type: 'eq' | 'and' | 'or' | 'not';
|
||||
field?: string;
|
||||
value?: string;
|
||||
left?: ExprNode;
|
||||
right?: ExprNode;
|
||||
operand?: ExprNode;
|
||||
}
|
||||
|
||||
function parseExpr(input: string): ExprNode;
|
||||
function evaluateExpr(node: ExprNode, values: Record<string, unknown>): boolean;
|
||||
```
|
||||
|
||||
不引入外部依赖,不使用 eval。
|
||||
|
||||
### 6.5 Dashboard 图表增强
|
||||
|
||||
**Schema 扩展:** Dashboard 页面支持 widgets 声明:
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "统计概览"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "stat_card"
|
||||
entity = "customer"
|
||||
title = "客户总数"
|
||||
icon = "team"
|
||||
color = "#4F46E5"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "bar_chart"
|
||||
entity = "customer"
|
||||
title = "客户地区分布"
|
||||
dimension_field = "region"
|
||||
metric = "count"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "pie_chart"
|
||||
entity = "customer"
|
||||
title = "客户类型分布"
|
||||
dimension_field = "customer_type"
|
||||
metric = "count"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "funnel_chart"
|
||||
entity = "customer"
|
||||
title = "客户等级漏斗"
|
||||
dimension_field = "level"
|
||||
dimension_order = ["potential", "normal", "vip", "svip"]
|
||||
metric = "count"
|
||||
```
|
||||
|
||||
**图表库:** 使用 `@ant-design/charts`(Ant Design 生态一致,支持按需引入)。
|
||||
|
||||
**后端新增:** timeseries 聚合 API:
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{id}/{entity}/timeseries?time_field=occurred_at&time_grain=week&start=2026-01-01&end=2026-04-17
|
||||
|
||||
响应:{ "data": [{ "period": "2026-W01", "count": 12 }, ...] }
|
||||
```
|
||||
|
||||
SQL 实现:`date_trunc('week', (data->>'occurred_at')::timestamp)`
|
||||
|
||||
**数据钻取:** 图表点击维度值时跳转到 CRUD 页面并自动带上筛选条件。`PluginCRUDPage` 支持从 URL query 参数初始化筛选。
|
||||
|
||||
### 6.6 前端文件拆分
|
||||
|
||||
| 当前文件 | 行数 | 拆分方案 |
|
||||
|---------|------|---------|
|
||||
| `PluginGraphPage.tsx` | 1081 | → `graphRenderer.ts` + `graphLayout.ts` + `graphInteraction.ts` |
|
||||
| `PluginCRUDPage.tsx` | 617 | → `CrudTable.tsx` + `CrudForm.tsx` + `CrudDetail.tsx` |
|
||||
| `PluginDashboardPage.tsx` | 647 | → `DashboardWidgets.tsx` + `dashboardTypes.ts` |
|
||||
|
||||
拆分后每个文件控制在 400 行以内。
|
||||
|
||||
### 6.7 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `apps/web/src/components/EntitySelect.tsx` | 新增 |
|
||||
| `apps/web/src/pages/PluginKanbanPage.tsx` | 新增 |
|
||||
| `apps/web/src/utils/exprEvaluator.ts` | 新增 |
|
||||
| `apps/web/src/pages/PluginCRUDPage.tsx` | 重构 — 拆分 + 批量操作 + entity_select + visible_when |
|
||||
| `apps/web/src/pages/PluginGraphPage.tsx` | 重构 — 拆分 |
|
||||
| `apps/web/src/pages/PluginDashboardPage.tsx` | 重构 — 图表 + 拆分 |
|
||||
| `apps/web/src/pages/PluginTreePage.tsx` | 优化 — 懒加载 |
|
||||
| `apps/web/src/api/plugins.ts` | Schema 类型扩展 |
|
||||
| `apps/web/src/api/pluginData.ts` | 新增 batch / timeseries / cursor API |
|
||||
| `apps/web/src/App.tsx` | Kanban 路由注册 |
|
||||
| `crates/erp-plugin/src/handler/data_handler.rs` | 新增 PATCH / batch 端点 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | batch / timeseries / partial update(PATCH 只合并 data 中的字段) |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 新增 `build_patch_sql` 部分更新 SQL 构建器 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| Generated Column 的 ALTER TABLE 锁表 | 中 | 中 | 插件安装时在低峰期执行;万级数据以内锁表时间 < 1s |
|
||||
| pg_trgm 索引空间开销(约 2-3x 原始文本) | 低 | 低 | 只为 searchable 的短文本字段创建 |
|
||||
| 行级权限的部门查询性能 | 中 | 中 | Redis 缓存部门树,TTL 10 分钟 |
|
||||
| 批量操作事务过大 | 低 | 中 | 上限 100 条;超过则分批执行 |
|
||||
| 前端重构引入回归 | 中 | 高 | 逐文件拆分,每步验证现有功能不变 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 不在范围内(后续版本)
|
||||
|
||||
以下内容在本次设计中**不涉及**,记录为已知需求:
|
||||
|
||||
- WASM Guest 业务逻辑增强 (L2/L3 插件模型)
|
||||
- 插件版本升级迁移框架
|
||||
- 跨插件通信 (事件契约 + 只读查询)
|
||||
- 插件间 RPC / 自定义 API 端点
|
||||
- 插件市场 / 分发架构
|
||||
- CRM 新增实体 (lead / opportunity / activity)
|
||||
- WIT 接口版本化
|
||||
- 图谱 LOD + WebGL 渲染
|
||||
- Iframe / Web Component 自定义 UI
|
||||
|
||||
这些将在后续的设计规格中详细展开。
|
||||
Reference in New Issue
Block a user