低代码平台开发总览
适用端:后端 + Web | 类型:机制
定制功能
本页覆盖的四个域(数据建模、表单设计、数据接口、门户)与取号管理,上游 yudao 一个都没有。上游只有代码生成器(生成 Java/Vue 源码,产物脱离平台运行)。本项目的低代码是「运行时解释 JSON 产物」,两者不是同一类东西。
这一页是 01 域的地图:先讲清「一张表怎么变成一个能用的业务功能」这条主链路,再讲清四个域各自的边界与它们之间唯一的耦合点。要改具体某一域,读完这页再进对应子页。
它解决什么问题
一条业务功能从零到能用,在本平台要走完四段。每一段落在一个不同的模块里,段与段之间只通过数据库表和 Spring 事件耦合,没有跨模块的方法调用:
全链路最贵的一类缺陷
这条链上每一段的失败模式都是「不报错」:DDL 执行了一半没法撤、菜单建出来了但路径形态错一格就白屏、权限资源没登记则页面一切正常只是行级过滤静默失效、表名集合是内存态一重启就空。所以四个域的代码里塞满了「宁可失败得明明白白」的取舍——读到那些看似过度的校验和刻意同步的事件时,先假设它有理由,再去注释里找。
四个域的边界
| 域 | 管什么 | 不管什么 | 子页 |
|---|---|---|---|
| 数据模型 | 物理表的登记与结构变更(DDL) | 不生成代码、不管业务数据 | 数据模型 |
| 表单设计 | JSON 产物 → 可访问可授权可回滚的表单 | 不建表、不改表 | 表单设计后端、表单运行时 |
| 数据接口 | 把一段 SQL/JSON/HTTP 变成一个可调用的取数端点 | 不管表结构、完全没有行级权限 | 数据接口 |
| 门户 | 首页看板的布局协议与发布 | 不产数据,只摆放取数卡片 | 门户 |
| 取号管理 | 业务单据编号的生成与并发安全 | 不关心编号存在哪张表 | 取号管理 |
| 自研控件 | 设计器里能拖出什么、前端怎么渲染 | — | 自研控件开发 |
数据接口与表单是两条独立的路
两者都能「查一张表的数据」,但不共享任何权限机制。表单运行时的行级过滤在 SQL 构建层介入(FormDesignSqlBuilder 6 个注入点),字段裁剪在 Service 层;数据接口走的是 SqlExecutor + SqlPolicyValidator,没有行级权限这一层。给数据接口配的行级规则不会被调用。这是已知边界,不是待修的缺陷。
核心机制
唯一的跨模块耦合点:一个事件 + 一份契约 DTO
enlev-module-system 依赖 enlev-module-infra,反向不行。所以 infra 侧的表单发布要驱动 system 侧的权限登记,只有一条路:广播事件,让 system 来监听。
| 件 | 位置 | 作用 |
|---|---|---|
FormDesignPublishedEvent | infra api/formdesign/event/ | 携带一份 FormResourceDescriptorDTO |
FormDesignDeletedEvent | 同上 | 携带改写前的原编码(不是墓碑编码) |
FormResourceDescriptorDTO | infra api/formdesign/dto/ | 跨模块契约;Item.isGrantworthy:142 决定哪些列值得落资源行 |
FormResourceDescriptorBuilder | infra api/formdesign/ | 组装契约,build:61;降级约定见子页 |
FormDesignPublishedListener | system framework/permission/core/dataset/ | @EventListener(不是 AFTER_COMMIT),onPublished:40 |
DatasetResourceService | system service/permission/ | register:88 五步 / unregister:119 |
这个事件必须同步、必须在事务里
FormDesignPublishedListener 的类注释把这个取舍写得很直白:换成 @TransactionalEventListener(AFTER_COMMIT) 的话,失败时表单已经发布出去了,而它的数据集资源、敏感字段资源、语义槽位一个都没有——页面一切正常,只有行级/字段级权限悄悄地全都不生效。这正是本项目最贵的缺陷形态:配了、成功了、没生效、没提示。代价是「权限模块出问题会挡住表单发布」,这个代价是明说的、看得见的。
反向的耦合也有,但走的是 FormDesignApi(infra 提供给 agent 模块),不涉及 system。previewPublishMenu 就是只在这里暴露、没有 admin 端点的一个例子。
原生模块怎么进这套体系:@PermissionDataset
在线表单靠发布事件登记数据集。原生模块(system_users、system_dept 这些)没有「发布」这个动作,走的是另一条路:在 DO 上标注解,启动期扫描登记。
@PermissionDataset(value = "system:user", name = "用户")
public class AdminUserDO extends TenantBaseDO { ... }全仓真正标注了的只有 2 个:DeptDO.java:19(system:dept / 组织)与 AdminUserDO.java:23(system:user / 用户)。
| 件 | 位置 |
|---|---|
| 注解定义 | enlev-framework/enlev-common/.../biz/system/permission/annotation/PermissionDataset.java |
| 扫描器 | system framework/permission/core/dataset/NativeDatasetScanner.java |
| 登记 Runner | system framework/permission/runner/NativeDatasetRegisterRunner.java(@Order(6)) |
NativeDatasetRegisterRunner 补的是行级权限的另一半:在此之前,进得了 RuleDataPermissionRule 表名集合的只有在线表单,原生模块的表一张都没有——在授权页上给它们配的行级规则配了不会被调用,且不报错。
六个启动 Runner:顺序是契约
system framework/permission/runner/ 下六个 ApplicationRunner,@Order 1→6,新增 Runner 的 @Order 必须排在它依赖的那个之后:
| @Order | Runner | 做什么 | 不跑会怎样 |
|---|---|---|---|
| 1 | ResourceMigrateRunner | system_menu 按钮层 → sys_resource | 后面全没数据。🔴 幂等判据在塌缩开启后换了:原本「按 menu_id 判断已迁移就跳过」不再成立,实测非目录菜单里 341 / 666 个已没有承载自己 menu_id 的存活资源行(塌缩前是 0) |
| 2 | GrantMigrateRunner | system_role_menu → sys_grant | 🔴 migrate() 是 insert-only(只补不删)。旧表还有写入方时每次启动都跑,会构成一条单向的半通路 |
| 3 | PermissionScanRunner | 扫描代码里所有 @PreAuthorize("@ss.hasPermission('...')"),按命名约定推导资源树、补缺失的中间目录节点 | 「代码里有但库里没有」的叶子权限点不会被发现——意味着某个功能永远鉴权失败 |
| 4 | BuiltInRuleSeedRunner | 把 8 个内置规则模板的描述行写进 sys_rule(按 template 判重,幂等) | 授权页的规则下拉框是空的 |
| 5 | DatasetTableRegisterRunner | 按存活的 DATASET 行重建 RuleDataPermissionRule 的表名集合 | 🔴 所有已配好的行级权限规则静默失效。查询照常返回结果,只是不再有过滤,越权数据就这么出去了,没有报错、没有日志、界面完全正常 |
| 6 | NativeDatasetRegisterRunner | 扫描 @PermissionDataset,登记原生表 | 原生模块的行级规则配了不会被调用 |
表名集合是内存态的
RuleDataPermissionRule.getTableNames() 是框架决定「要不要改写这条 SQL」的依据,它只在表单发布时被填进去,应用一重启就空了。@Order(5) 那个 Runner 存在的全部意义就是把它重建回来。这就是为什么它排在权限扫描之后、原生登记之前:原生登记也要往这个集合里加表名,得先有一个重建好的集合。
两套 SQL 安全机制,判据完全不同
这是最容易搞混的一处。两个域都要执行用户写出来的 SQL,但走的是两条独立的实现,安全模型相反:
| 数据模型(DDL) | 数据接口(DML) | |
|---|---|---|
| 类 | SqlSafeGuard | SqlPolicyValidator |
| 模型 | 标识符白名单 | JSqlParser 解析 + 策略黑名单 |
| 为什么 | DDL 里全是标识符,没有值可绑定;白名单比转义可靠 | DML 里有真正的参数,用 PreparedStatement 绑定,只需管住「语句形态」 |
| 兜底 | 受管表前缀拒绝 | 框架表写前缀拒绝(act_/ai_/bpm_/flw_/infra_/qrtz_/sys_/system_) |
两者不共用代码,改一边不会影响另一边。详见各子页。
数据模型
低代码域共 14 张定制表,全部 infra_ 前缀,完整列清单由脚本生成于 docs/.generated/tables-lowcode.md(不是站点页面):
| 组 | 表 |
|---|---|
| 数据模型 | infra_data_model、infra_data_model_log、infra_data_source_config |
| 表单设计 | infra_form_design、infra_form_design_version、infra_form_design_menu、infra_form_design_data_log |
| 数据接口 | infra_data_interface、infra_data_interface_version、infra_data_interface_log |
| 门户 | infra_portal、infra_portal_version |
| 取号 | infra_bill_template、infra_bill_template_log |
租户维度不统一
infra_form_design 是 @TenantIgnore 全局表,没有 tenant_id 列(一张表单登记一次、所有租户共用);而 infra_form_design_data_log 有 tenant_id。同组两张表不同口径,别按「同一模块的表都一样」推断。逐表核对见各子页。
三张表的唯一索引不含 deleted
infra_form_design.uk_code、infra_data_interface.idx_code(非唯一,更危险)、以及 system 侧的 sys_slot_mapping.uk_resource_slot。软删的行会继续占住编码,所以:
- 表单软删必须把
code改写成墓碑<code>_del_<id>(tombstoneCode:366); - 数据接口的
code撞了没有任何数据库层的保护; - 槽位映射必须物理删除,软删会被墓碑永久占住。
怎么扩展
我要加一个新的低代码域(比如「报表」)
按现有四域的形状,最小可行清单:
- 表:主表 +
_version表(如果要发布/回滚)+_log表(如果要审计)。命名沿用infra_<域名>/infra_<域名>_version。DDL 记得跑node tools/gen-tables.mjs更新语料。 - DO:继承
BaseDO(全局表)或TenantBaseDO(租户表),先决定租户维度——这个决定一旦上线就很难改。 - Service:唯一入口。发布类动作要
@Transactional,且乐观锁每条写路径自己查行数(拦截器冲突时不抛异常)。 - Controller:权限串沿用
infra:<域名>:{query|create|update|delete|publish}。启动期PermissionScanRunner(@Order 3) 会自动把缺失的中间目录节点补进sys_resource,但叶子权限点漂移只会告警不会自建。 - 如果要参与行级权限:得走
@PermissionDataset或发布事件登记成 DATASET,并让表名进RuleDataPermissionRule的集合。漏这一步的表现是「规则配了不生效且不报错」。 - 前端:
api/infra/<域名>/index.ts↔views/infra/<域名>/index.vue+ 同级XxxForm.vue(这是本仓的 CRUD 配对约定)。
我要给表单加一个动作权限
见表单设计后端 · 怎么扩展。要点:改 buildMenuConfig 的 actions 数组、改运行时的端点动作映射、改前端 GRANT_ACTION_OPTIONS——三处没有任何自动对齐,且已发布的存量表单不会自动补这一行。
我要加一个自研控件
见自研控件开发 · 八步。要点:两张注册表(form-create 的与 form-designer 的)不是同一张,且 components[tag] 的查找发生在 isNativeTag 分支之前。
坑与注意
跨域的、不属于任何单一子页的坑:
- 🔴 业务模块只有 5 个:
enlev-module-{system,infra,bpm,ai,agent}。上游的enlev-module-im、支付、商城、CRM 等已连目录一起删除(12347efcd1),mvn -pl enlev-module-im必然失败——不是因为被注释,是因为不存在。别按上游 yudao 的模块清单猜这里有什么,详见后端架构与模块依赖。 - 🔴 模块是扁平 jar,没有
-api/-biz拆分。api只是同一个构件里的一个 package。这是与上游 yudao 最大的结构差异,代码生成器模板已按扁平模块写好,调整模块结构要同步改模板。 src/lib/form-create与src/lib/form-designer是上游源码副本,要求零 diff。不要修改、重排格式或「顺手优化」。vite.config.ts里对应的 alias 顺序敏感。详见自研控件开发。schema_fingerprint恒为 NULL 是刻意的(FormDesignDO:299-315),而 DDL 的列注释描述了一个不存在的复核行为。别照着列注释实现,也别「修好」它。- 「版本」这个词在四个域里指四件不同的事:
infra_form_design.version(乐观锁)/published_version(线上第几版)/schema_version(JSON 产物结构版本)/infra_form_design_version(快照表);数据模型没有版本表;门户的版本机制与表单的不是同一套代码;数据接口有infra_data_interface_version但语义又是另一套。 - DDL 不进事务、不可回滚(MySQL 的限制,不是设计选择)。数据模型那套五道闸存在的全部意义就是把「改坏了没法撤」的概率压到最低。
pnpm test:e2e是坏的——playwright.config.ts和tests/e2e/都不存在。不要运行也不要尝试补全。pnpm dev-server指向上游芋道演示后端,不是本地服务。别用它验证本地后端改动。enlev-module-agent没有src/test/resources/application-unit-test.yaml,其 156 个测试绝大多数是纯 Mockito。要在那里加 DB 测试得先补这份配置。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
测试
| 域 | 位置 | 规模 |
|---|---|---|
| 表单设计 / 运行时 | infra src/test/java/.../formdesign/ | 60 个测试文件 |
| 数据模型 | infra src/test/java/.../datamodel/ | 见子页 |
| 数据接口 | infra src/test/java/.../datainterface/ | 见子页 |
| 取号 | infra src/test/java/.../bill/ | 84 个 @Test,跨 7 个类(含 32 线程 / 200 任务的并发证明) |
| 门户 | infra src/test/java/.../portal/ | 26 个 |
| 权限派生 | system DatasetResourceServiceImplTest | 22 个 @Test,纯 Mockito |
基类在 enlev-spring-boot-starter-test:BaseDbUnitTest(H2,profile unit-test,需 /sql/clean.sql)、BaseMockitoUnitTest、BaseRedisUnitTest。
未证实
- 本页的链路图与各域边界来自逐域读码,但没有端到端跑一遍完整的「建表 → 配表单 → 发布 → 授权 → 运行时」链路去验证每一段的衔接。各子页标注的实测项是局部的。
- 六个 Runner 的
@Order取值与类注释逐个核对过,但启动期实际执行顺序没有用日志验证(Spring 对同@Order值的排序未定义,此处六个值互不相同所以不受影响)。 - 「341 / 666」「12 行悬挂」这类数字全部来自源码注释,是历史快照。
GrantDecisionService:482-484专门警告「别把任何一个数字当常量用」。
相关页面
本域子页
跨域
