6 专家组深度评审后的基座优先改进方案: - JSONB 存储优化 (Generated Column + pg_trgm + Keyset Pagination) - 数据完整性框架 (ref_entity + 级联策略 + 字段校验 + 循环引用检测) - 行级数据权限 (self/department/department_tree/all 四级) - 前端页面能力增强 (entity_select + kanban + 批量操作 + Dashboard 图表)
27 KiB
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. 设计目标
- JSONB 存储优化 — 百万级数据下列表查询 p95 < 200ms,搜索 p95 < 300ms
- 数据完整性框架 — 应用层外键校验、级联策略、字段校验、循环引用检测
- 行级数据权限 — 支持 self/department/department_tree/all 四级数据范围
- 前端页面能力增强 — 关联选择器、看板页面、批量操作、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 示例:
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 类型和类型转换表达式。
元数据表:
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 后,安装流程改为:
- 首次安装:
CREATE TABLE包含所有 Generated Column。 - 重新安装(同版本):
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
- 新增字段:
- 插件卸载时:表被删除,元数据自动清理。
dynamic_table.rs 新增 migrate_table 方法,接受已有列列表和目标列列表,生成增量 DDL。
3.2 pg_trgm 模糊搜索加速
迁移文件: 在 erp-server/migration 中新增迁移启用 pg_trgm 扩展:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
索引创建: create_table 中 searchable 字段的索引从普通 B-tree 改为 GIN 三元组:
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 字段:
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(游标不嵌入排序信息,保持无状态)。
-- 第一页
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 查库:
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 字段:
pub struct PluginField {
pub name: String,
pub field_type: PluginFieldType,
// ...已有字段...
pub ref_entity: Option<String>, // 新增:引用的实体名
}
manifest 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 结构:
pub struct PluginRelation {
pub entity: String, // 关联实体名
pub foreign_key: String, // 关联实体中的外键字段名
pub on_delete: OnDeleteStrategy, // 级联策略
}
pub enum OnDeleteStrategy {
Nullify, // 置空外键字段
Cascade, // 级联软删除
Restrict, // 存在关联时拒绝删除
}
manifest 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 子结构:
pub struct FieldValidation {
pub pattern: Option<String>, // 正则表达式
pub message: Option<String>, // 校验失败提示
}
manifest 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 字段:
[[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 扩展:
[[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"]
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:
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 的迁移中,同时执行以下补偿:
-- 为所有拥有 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 新增字段:
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:
[[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:
[[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:
[[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
pub enum BatchAction {
BatchDelete { ids: Vec<Uuid> },
BatchUpdate { ids: Vec<Uuid>, data: serde_json::Value },
}
批量操作在单个事务中执行,有上限(默认 100 条)。
6.4 visible_when 表达式增强
当前: 只支持 field == 'value' 单一等式。
增强后支持:
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 行递归下降表达式解析器:
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 声明:
[[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
这些将在后续的设计规格中详细展开。