# 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, // 保留,向后兼容 pub page_size: Option, pub cursor: Option, // 新增:Base64 编码的游标 pub search: Option, pub filter: Option, pub sort_by: Option, pub sort_order: Option, } ``` `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, // 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, // 新增:引用的实体名 } ``` 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, // 正则表达式 pub message: Option, // 校验失败提示 } ``` 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` 字段(注意:用户可通过岗位属于多个部门)。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 }, BatchUpdate { ids: Vec, 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): 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 这些将在后续的设计规格中详细展开。