Skip to content

表单设计后端 ​

适用端:后端 | 类型:机制

定制功能

在线表单(菜单「表单设计」)是本项目自研能力。上游 yudao 只有代码生成器(生成 Java/Vue 源码),没有「配一张表单、绑一张已有表、发布成一个菜单」这条链路。本页讲的是设计期与发布链路;渲染、提交、行过滤、字段裁剪在表单运行时与扩展点。

表单设计负责把一份 JSON 产物变成一个可访问、可授权、可回滚的业务表单。它有三件上游完全没有的事:发布时派生权限资源、菜单由前端代建、版本快照可回滚。

前置阅读:数据库约定与表总览、后端模块架构。

它解决什么问题 ​

两条前提决定,先记住再往下看:

  1. 发布不建物理表。FormDesignDO 的类注释写明策略是「只绑定已有表」——不建表、不改表。代价是发布时必须真去核实元数据:表和列由 DBA 掌控,平台不核实的话错误会推迟到运行时才暴露。
  2. 菜单不是后端建的。buildMenuConfig 只返回一份配置对象,前端拿它去调 /system/menu/create,建完再回调 POST /infra/form-design/bind-menu。原因是模块依赖单向:system → infra,infra 不能反向调 system。

「菜单在库里但点不开」

菜单形态错一格就白屏,且不报任何错。判据在 buildMenuPath:676-679:挂顶层必须给绝对路径 /form-design-run/{code},挂目录下必须给相对路径 form-design-run/{code}。详见下文。

代码在哪 ​

后端路径前缀 enlev-module-infra/src/main/java/cn/enlev/ainb/module/infra/,system 模块前缀 enlev-module-system/src/main/java/cn/enlev/ainb/module/system/:

职责文件关键方法
主 DO(含四个易混列辨析)dal/dataobject/formdesign/FormDesignDO.java@TableName("infra_form_design")、menuId:250、version:347
版本 DOdal/dataobject/formdesign/FormDesignVersionDO.javaMAX_KEEP_VERSIONS = 100
菜单关联 DOdal/dataobject/formdesign/FormDesignMenuDO.javapermission:52-56 从未被写过
发布主流程service/formdesign/FormDesignServiceImpl.javapublish:517、buildMenuConfig:616、buildMenuPath:676、bindMenu:698、unpublish:727、deleteFormDesign:330、tombstoneCode:366
发布校验service/formdesign/FormDesignPublishValidator.java(2687 行)validate:219、inspect:254、inspect0:261
版本快照service/formdesign/FormDesignVersionServiceImpl.javacreateVersion:43、buildSnapshot:64、trimOldVersions:97、rollback:121
跨模块契约 DTOapi/formdesign/dto/FormResourceDescriptorDTO.javaSUB_FIELD_SEPARATOR:51、Item.isGrantworthy:142
契约组装(降级约定)api/formdesign/FormResourceDescriptorBuilder.javabuild:61
事件api/formdesign/event/{FormDesignPublishedEvent, FormDesignDeletedEvent}.java—
对外 APIapi/formdesign/{FormDesignApi, FormDesignApiImpl}.javapreviewPublishMenu 只在这里暴露
监听方(system 模块)framework/permission/core/dataset/FormDesignPublishedListener.javaonPublished:40
权限资源派生(system 模块)service/permission/DatasetResourceServiceImpl.javaregister:88、unregister:119、upsertDataset:440、applyDefaultEffectOnTypeUpgrade:293、reparentActions:513、syncChildren:533、tightenOnly:621、syncSlots:659
槽位推断framework/permission/core/dataset/SlotInferencer.javainfer:51
命名约定framework/permission/core/dataset/DatasetResourceNaming.javadataset()、child()、RUN_PERMISSION_PREFIX
Controllercontroller/admin/formdesign/FormDesignController.java21 个端点
前端 API / 列表ainb-ui-admin-vue3/src/api/infra/formDesign/index.ts、views/infra/formDesign/index.vue—

常量集中在 FormDesignServiceImpl:74-98:TOMBSTONE_CODE_SUFFIX = "_del_"、CODE_MAX_LENGTH = 100、MENU_PATH_PREFIX = "form-design-run/"、RUNTIME_COMPONENT = "infra/formDesign/render/index"、DEFAULT_MENU_ICON = "ep:document"、MENU_TYPE_MENU = 2、MENU_TYPE_BUTTON = 3。

核心机制 ​

发布:六步,全在一个事务里 ​

publish:517-558 是 @Transactional(rollbackFor = Exception.class),六步的顺序是有讲究的:

java
FormDesignDO formDesign = validateFormDesignExists(reqVO.getId());
// 1. 校验配置与数据库元数据
publishValidator.validate(formDesign);
// 2. 首次发布必须指定上级菜单
boolean firstPublish = formDesign.getMenuId() == null;
if (firstPublish && reqVO.getParentMenuId() == null) {
    throw exception(FORM_DESIGN_MENU_PARENT_REQUIRED);
}
// 3. 存版本快照
Integer newVersion = versionService.createVersion(formDesign, reqVO.getChangeSummary());
// 4. 更新状态与线上版本号
updateObj.setStatus(FormDesignStatusEnum.PUBLISHED.getStatus());
updateObj.setPublishedVersion(newVersion);
updateByIdWithLock(updateObj, formDesign);
// 5. 组装建菜单所需配置,交给前端去调 /system/menu/create
respVO.setNeedCreateMenu(firstPublish);
// 6. 广播发布事件
publishResourceEvent(formDesign);

第 6 步的事件必须在事务里、必须同步。publish:552-555 的注释把这个取舍写得很直白:

⚠️ 监听方是同步的、跑在本事务内,失败会让整个发布回滚。这是刻意的 —— 「表单发布成功了、但它的权限资源没登记」属于本项目最贵的缺陷形态(页面一切正常,只有行级/字段级权限悄悄全都不生效,没有任何提示)。宁可发布失败得明明白白。

FormDesignPublishedListener 的类注释补了另一半理由:换成 @TransactionalEventListener(AFTER_COMMIT) 的话,失败时表单已经发布出去了,而它的数据集资源、敏感字段资源、语义槽位一个都没有。代价是「权限模块出问题会挡住表单发布」——这个代价是明说的、看得见的。

校验分组短路 ​

FormDesignPublishValidator.inspect0:261 按组校验,基础组有错立即返回。这是为了不让次生错误淹没真因:主表都没绑,后面几十个「字段映射的列不存在」全是噪音。

inspect(体检,用户手点)与 validate(发布,强制)共用同一套判据,区别只在 inspect:510-511 会清一次表结构元数据缓存——体检是低频动作,这个代价换的是「看到的是最新结构」。

菜单路径:顶层绝对、嵌套相对 ​

buildMenuPath:676-679 全文只有两行,但注释有 15 行,因为写错的表现是「菜单在库里、点进去白屏」且不报错:

java
private String buildMenuPath(String code, Long parentMenuId) {
    boolean topLevel = parentMenuId == null || parentMenuId == 0L;
    return topLevel ? "/" + MENU_PATH_PREFIX + code : MENU_PATH_PREFIX + code;
}
  • 挂在目录下(parentId 非空且非 0):给相对路径 form-design-run/{code},前端 routerHelper 拼到父目录 path 后面。
  • 挂在顶层:前端 routerHelper.generateRoute 会把这条「parentId=0 且带 component 的非目录菜单」包成一个 Layout 根路由,并直接拿 path 当根路由地址。vue-router 要求根路由必须以 / 开头,相对路径会抛 Route paths should start with a "/"。

组件名由 buildComponentName:686 生成 PascalCase:customer_order → FormDesignRunCustomerOrder。前端 keep-alive 依赖它唯一。

实测过的真实路由

「文档演示 - 设备台账」的实际路由是 /infra/form-demo/form-design-run/doc_device_ledger(menu id=9000051144,挂在 /infra/form-demo 目录下,所以走的是相对路径分支)。它不是 /form-design-run/doc_device_ledger。调试时按顶层形态去猜会找不到路由。

七个按钮权限,一个都不能少 ​

buildMenuConfig:641-652 建 7 个按钮,权限串是 infra:form-design-run:{code}:{action},名字是 {表单名}{查询|新增|修改|删除|导出|导入|被引用}:

action名称谁在用
query查询运行时 config/get/page/distinct-values/data-logs/check-unique
create新增POST /{code}/create
update修改update、plain-field、invoke-button
delete删除delete、delete-batch
export导出export
import导入import/preview、import/execute、import/template
reference被引用reference-options(与源表单的 query 一起校验)

export/import 不是「可选按钮」:FormDesignRuntimeController 的对应端点硬性校验这两个串。不建的话,超级管理员走「全部权限」语义看不出问题,而普通管理员点导出必然 403——表现是「按钮点了没反应」,看不出是权限问题。

reference 更隐蔽:它不是一个用户点得到的按钮,但它是一个要能被授权的东西。不建这一行,管理员想收紧或放开「本表单能不能被别的表单关联」时根本没有可授权的对象。

塌缩之后这段注释已过期

:636 写着「GrantDecisionService.hasPermission 对【资源行不存在】直接返回 false」——这是权限塌缩之前的语义,现在不成立了。塌缩开启后,动词串被翻译成身份行去查,身份行 default_effect=ALLOW 且链上零授权时会回落放行。实测已有 3 张已发布表单被打穿。详见授权与决策引擎。

权限资源派生:五步 ​

DatasetResourceServiceImpl.register:88 由 FormDesignPublishedListener.onPublished:40 同步调用,五步全在一个事务里:

步方法做什么关键取舍
1upsertDataset:440建/升级 DATASET 锚点,权限串 infra:form-design-run:{code},dataset_table = mainTable.toLowerCase()若该串已被约定式推导建成 MENU 节点则升级它而非另建——另建会让六个动作挂在一个节点下、列/字段挂在另一个下,祖先链断成两截
2reparentActions:513把 infra:form-design-run:{code}: 前缀的 ACTION 行挂到 DATASET 下挂在根下时「给整张表单授权」落不到一个节点上,决策算法第 3 步走祖先链就继承不到
3syncChildren:533按需落行:只有 sensitive || masked 的列/字段才建资源行sensitive → DENY,仅脱敏 → 保持 ALLOW(:642);已存在行的 default_effect 只允许单向收紧
4syncSlots:659语义槽位只补不改自动推断是「猜」,人工在规则编辑器里改过的才是准的;每次发布都覆盖会把人工修正静默改回去
5ruleDataPermissionRule.registerTable:108让 MyBatis 拦截器认识这张表没有这一步,规则配了也不会被框架调用

第 3 步的「按需落行」判据是 FormResourceDescriptorDTO.Item.isGrantworthy:142——一张几十列的表不会造出几十个资源行,只有值得授权的才落。

tightenOnly:621-623 全文只有一行,但注释解释了两个方向代价完全不对等:

java
private static boolean tightenOnly(String current, String want) {
    return "ALLOW".equals(current) && !"ALLOW".equals(want);
}
  • 跟着放开(DENY→ALLOW)⟹ 敏感列的行重新可见,是越权,全程 200 无异常,没有人会报「我怎么突然能看见了」
  • 不跟着放开(停在 DENY)⟹ 表现是「这张表单查不出数据」,一定会有人来报,且管理员在资源页上把它改回 ALLOW 就立刻恢复

⚠️ 判据必须与 GrantDecisionEngine#explain 的回落读法逐字节一致:那边写的是 "ALLOW".equals(self.getDefaultEffect()),也就是 null、空串、脏值一律等价于 DENY,不是第三态。这里若把 null 当「未知」而放行覆盖,就会把一行脏数据解释成 ALLOW 并写回去。

跨模块契约的降级约定 ​

FormResourceDescriptorBuilder.build:61 把表单产物翻译成 FormResourceDescriptorDTO。它的降级约定是:除「主表为空返回 null」外,解析失败一律降级为「这一项没有」,不抛(:29-37)。

理由是发布不该被一份畸形 JSON 挡住——但代价是「某一项静默缺失」,所以 register:89-93 对描述不完整的情况打 warn 并返回空结果,不抛异常。

下线:菜单 id 列表的顺序是契约 ​

unpublish:727-779 返回一份要交给前端回收的菜单 id 列表。:733-739 的注释说清了这份列表的顺序不是任意的:

bindMenu 先插父菜单再插按钮,selectListByDesignId 不带 ORDER BY,InnoDB 走 design_id 的二级索引 ⟹ 按主键升序返回,所以 menu_type=2 的父菜单永远排在第一个。前端因此只能整批交给 /system/menu/delete-list(它的判据排除了同批选中的子菜单),绝不能逐个调 /system/menu/delete —— 单删见「还有子菜单」就抛 MENU_EXISTS_CHILDREN,父菜单第一个必然失败,7 个按钮随后删光,侧边栏里留下一棵孤儿父菜单且不报错。

删除:墓碑编码 + 用原编码广播 ​

deleteFormDesign:330-358 有两处不直观:

① 软删必须把 code 改写成墓碑 <code>_del_<id>(tombstoneCode:366-370)。uk_code 是不含 deleted 的全局唯一索引,只置 deleted=1 的话这条墓碑会一直占着编码,而页面上查不到它(selectByCode 自带 deleted=0)——于是「删掉表单、用同一个编码重建」必然撞 Duplicate entry,AI 建表流程要走到 form_save_draft 才炸,用户看到的是一串 SQL 异常。

后缀带 id 而不是固定值:同一个编码可能被反复「建了删、删了建」,固定后缀会让第二个墓碑撞唯一索引——而那正是 AI 反复重试时的路径。

② 删除事件必须用改写前的原编码广播(:352-357)。锚点的权限串是拿原编码建的,传墓碑编码会一条都找不到,于是静默地什么都不做。漏掉这一步的后果是游离锚点 + 编码被重新占用时继承死表单的权限配置。

注销:物理删槽位,但不摘表名 ​

unregister:119-161 有两个刻意的不对称:

  • 槽位必须走物理删除:uk_resource_slot(tenant_id, resource_id, slot) 不含 deleted,软删的话这个组合会被墓碑永久占住。而资源行本身只是软删,重新占用同一个表单编码时 register 会建出新 id,所以物理删掉旧槽位不会影响那张新表单。
  • 刻意不把 dataset_table 从拦截器的表名集合里摘掉:一张物理表可能被多张表单绑定,摘掉会把另一张活表单的行级过滤一起关掉。留着是安全的——拦截器按表名反查数据集(selectListByDatasetTable 自带 deleted=0),查不到锚点就是不加过滤。下次启动 reloadRegisteredTables 按存活的 DATASET 行重建集合,这条残留自动消失。

收集子树用 parentId 逐层 BFS 而不用物化路径前缀查(:166-169):后者依赖 path 列被正确维护,而本方法跑在删除路径上,一旦某条历史行的 path 是空的,前缀查会静默漏掉它的子树,接着 deleteResourceList 的孤儿校验抛 RESOURCE_HAS_CHILDREN——用户看到的是「表单删不掉」,报错却指向资源树。

数据模型 ​

三张定制表,完整列清单见 docs/.generated/tables-lowcode.md(脚本生成物,不是站点页面)。

infra_form_design ​

这张表没有 tenant_id

infra_form_design 与 sys_resource 都是 @TenantIgnore 全局表——一张表单登记一次、所有租户共用同一份资源与槽位。租户差异只体现在 sys_grant(同一个字段资源,各租户各自授权)。而 infra_form_design_data_log 有 tenant_id。别按「同一模块的表都一样」去推断。

索引只有两个:PRIMARY(id)、uk_code(code)(唯一,不含 deleted)、idx_category(category)。

四个易混列 ​

辨析写在 FormDesignDO:292-297、318-345:

列是什么什么时候变
versionMP 乐观锁整数每次 UPDATE 自增
published_version线上发布到第几版,0 = 从未发布发布时写入;下线归 0;回滚不动它
schema_version本行 JSON 产物(form_conf/form_fields/list_conf/table_binding)的结构版本破坏性变更时递增,供迁移器判断怎么升级
schema_fingerprint它绑定的那张物理表的结构摘要见下

schema_fingerprint 恒为 NULL,且 DDL 列注释在骗人

FormDesignDO:299-315 明说本轮「只加了列,没有写入方」,表单侧刻意不做指纹复核,本列恒为 null。实测全仓只有两处写它,都是 setSchemaFingerprint(null)(FormDesignServiceImpl:155 创建、:433 复制)。

但 DDL 的列注释写的是「体检时对比现值,用于发现『发布后表结构被绕过平台改动』;NULL=尚未发布或未记录」——这描述的是一个不存在的行为。别照着列注释去实现,也别「修好」它。

不做的四条理由(摘要):① B4-2 的闸是「DDL 执行前复核」,而表单设计不建表也不改表,这条路径上没有 DDL,没有「已经执行了一半、回不去」的代价要防;② FormDesignPublishValidator 已经逐列核过「映射的列必须在主表中存在」,报出来的是「哪个字段映射的哪一列没了」,比「两个哈希不相等」可操作得多;③ 哈希闸会对与本表单无关的结构变更也报警(DBA 把某个没用到的列加宽了),假阳性 + 无补救动作 = 比不拦更坏;④ 真要写入得对 MP 代码生成器的 TableField 再写一份规范化,与 SchemaFingerprint 吃的 TableColumnModel 不是同一个类型,两份指纹定义各自漂移正是本仓反复吃过的那类亏。

列还是留着:B4-1 要求两张表同构,而且它是事后取证的落点。

乐观锁:9 条写路径,每条都要自己查行数 ​

FormDesignDO:324-345 说「三件事必须同时成立,少一件这一列就是装饰」:@Version 注解、OptimisticLockerInnerInterceptor 已注册(本仓此前从未注册过)、每条 update 路径都带上当前 version 并检查影响行数。

本表有 9 条写路径(2 处 insert 不需要,7 处 update 需要),其中 2 条 MP 的拦截器结构上够不着(entity 为 null ⟹ 拦截器无处读 version):

  • FormDesignMapper#updateToDelete:手写 @Update SQL,版本条件是手写的。
  • FormDesignVersionServiceImpl#rollbackClearableColumns:update(null, wrapper),刻意不带版本条件——它与同事务里前一次带锁的 updateById 组成「先 CAS 再补写」,排他锁持锁到提交,中间没有别的事务能改这一行。🔴 这是顺序依赖,挪动调用顺序前必读该方法的注释(:198-208)。

拦截器在冲突时不抛异常

只是让 UPDATE 命中 0 行。所以每条路径都要自己检查行数并抛 FORM_DESIGN_RECORD_VERSION_CONFLICT。不检查的话表现是「接口返回成功、先提交的那次改动被静默丢弃」。

unpublish:767-771 还有一条相关的:版本条件刻意不写进 wrapper——updateObj 上带了非空 version,拦截器会自己往这个 wrapper 上 apply version = {0}(msId 以 .update 结尾且 ew 非 null 时走这一支),手写一遍 WHERE 里会出现两条。这也是那里不能再用 updateByIdWithLock 的原因:它走 updateById,够不着 wrapper。

⚠️ 别把 FORM_DESIGN_RECORD_VERSION_CONFLICT 与 FORM_DESIGN_VERSION_CONFLICT(1_001_204_067) 搞混:后者说的是表单绑定的业务数据行的乐观锁(TableBinding 里的 versionColumn),与本列无关。

infra_form_design_menu:permission 列从来没被写过 ​

DO 注释 :52-56 说它形如 infra:form-design-run:{code}:{action},但 bindMenu:708-721 只 set designId/menuId/menuType。权限串只活在 system_menu 里。这一列恒 NULL,别指望从它反查权限。

顺带:FormDesignMenuDO 的类注释 :15-16 写「1 条菜单 + 4 条按钮」,实际建 7 个——注释过期。

infra_form_design_version ​

uk_design_version(design_id, version) 唯一。版本号 = selectMaxVersionByDesignId + 1(createVersion:44)。快照含 11 个配置列 + logicRules/validateRules/formScript/extensionMode/submitHookTimeoutAction + schemaVersion(buildSnapshot:64-92)。

trimOldVersions:97 对超出 MAX_KEEP_VERSIONS(100) 的最老版本做物理删除,不是软删。

buildSnapshot:61-62 明确「只存配置相关字段,不存 status/publishedVersion/menuId 这类运行时状态——回滚时不该把『当时是否已发布』『当时挂在哪个菜单』一起还原」。

接口清单 ​

FormDesignController 21 个端点,权限串 6 个(infra:form-design:{query|create|update|delete|publish|rollback}):

方法路径权限
POST/infra/form-design/createcreate
PUT/infra/form-design/updateupdate
DELETE/infra/form-design/deletedelete
GET/infra/form-design/getquery
GET/infra/form-design/pagequery
GET/infra/form-design/inspectquery
GET/infra/form-design/simple-listquery
GET/infra/form-design/cell-format-contractsquery
GET/infra/form-design/widget-contractsquery
GET/infra/form-design/validation-patternsquery
POST/infra/form-design/copycreate
GET/infra/form-design/check-codequery
POST/infra/form-design/publishpublish
POST/infra/form-design/bind-menupublish
POST/infra/form-design/unpublishpublish
GET/infra/form-design/version-listquery
GET/infra/form-design/get-versionquery
POST/infra/form-design/rollbackrollback
GET/infra/form-design/data-sourcesquery
GET/infra/form-design/db-tablesquery
GET/infra/form-design/table-columnsquery

previewPublishMenu 不走 Controller

previewPublishMenu 在 Service(:561)与 FormDesignApi(:320)里都有,但没有对应的 admin 端点——它只给 agent 模块用。🔴 :569 的注释写着它与 publish 第 2 步是「同一个判据、同一行写法」,两处判据一旦分叉,AI 预览说「不用建菜单」而真发布却要求 parentMenuId。

运行时端点(/infra/form-design-run/**)与动作映射见表单运行时。

怎么扩展 ​

加一个按钮权限动作 ​

  1. buildMenuConfig:641-644 的 actions 数组加一项 {action, 中文名}。sort 由循环下标自动给出,不用手填。
  2. 🔴 FormDesignRuntimeController 的端点动作映射里加一条,否则新动作串建了却没人校验。映射是编程式的(串里含 code,@PreAuthorize 静态字符串做不到)。
  3. 前端动作词表 GRANT_ACTION_OPTIONS(views/system/resource/constants.ts)同步加一项——那份表与后端枚举没有任何自动对齐,只有一份手抄的黄金副本在 constants.spec.ts。
  4. 已发布的存量表单不会自动补这一行:只有重新发布才会走 buildMenuConfig。要么手工在菜单管理里补,要么让所有相关表单重发一次。

加一列进版本快照 ​

  1. FormDesignDO 加列 + DDL 加列。
  2. buildSnapshot:64-92 加一行 snapshot.put(...)。
  3. rollback:138-163 决定它走 updateObj.setXxx(...) 还是 rollbackClearableColumns。判据:可为 null 的列必须走后者——MP 的字段更新策略是 NOT_NULL,setXxx(null) + updateById 是「这一列别动」而不是「清空」,且不报任何错。
  4. 🔴 别漏第 2 步。buildSnapshot:79-82 记着这个坑:logicRules 原先漏了,后果是回滚把 formFields 还原到旧版本而规则仍是新版本的——规则里引用的字段可能已经不存在了,表现为「回滚之后联动莫名失效」,或者反过来「明明没配规则却有字段被置灰」。规则与字段必须同进同出。

让表单支持服务端业务扩展 ​

实现 FormDesignExtension(formCode() + beforeCreate/afterCreate/beforeUpdate/afterUpdate/beforeDelete),把该表单的 extension_mode 改成 2(SERVER_EXTENSION)。注册表用 @Autowired(required=false) List<FormDesignExtension> 自动收集。完整说明见表单运行时与扩展点。

坑与注意 ​

真会咬人的 ​

  1. 🔴 setMenuId(null) + updateById 是「别动这列」,不是「清空」。MP 的字段更新策略默认 NOT_NULL,为 null 的列整个不进 SET 子句,且不报任何错。必须用 LambdaUpdateWrapper#set。实测留下过 12 行悬挂数据,导致「删除说要下线、下线说没发布」的死循环:deleteFormDesign 的判据含 menu_id != null ⟹ 抛 BIND_MENU;unpublish 见 published_version 已归 0 ⟹ 抛 NOT_PUBLISHED。两条路互相指对方,这张表单永远删不掉。同一个悬挂还会让 publish 判错 firstPublish(它用 menuId == null 判定),于是「重新发布」也不再建菜单。守卫见 FormDesignUnpublishTest。
  2. 🔴 不给 menuId 打 updateStrategy = ALWAYS。clearableColumnsWrapper 的注释已说明那会波及所有局部更新路径,把整份设计产物清空。
  3. 乐观锁冲突不抛异常,只让 UPDATE 命中 0 行。9 条写路径每条都要自己查行数。
  4. 下线返回的菜单 id 列表必须整批交 /system/menu/delete-list,逐个调 /system/menu/delete 会在第一个就失败并留下孤儿父菜单。
  5. 删除事件必须用改写前的原编码广播,传墓碑编码会静默什么都不做。
  6. uk_code 不含 deleted,软删必须改写 code 成墓碑。
  7. register 是同步同事务的,权限模块抛异常会回滚整个发布。别把它「优化」成异步。
  8. syncSlots 只补不改,也不删多余的槽位(它可能是人工加的,推断只认识 owner/dept/status 三个)。
  9. tightenOnly 是单向的,且判据必须与决策引擎的回落读法逐字节一致(null/空串/脏值 = DENY)。
  10. infra_form_design_menu.permission 恒 NULL,别指望从它反查权限串。
  11. applyDefaultEffectOnTypeUpgrade:293 只在类型升级那一次填 default_effect。:257 记着事故:原来两处登记都无条件写 setDefaultEffect(DATASET.defaultEffect()),也就是每次启动都把这一列刷回 ALLOW,实测把塌缩迁移的成果在第一次重启时就抹掉了。default_effect 有两个合法的 DENY 来源(塌缩计划器、管理员手改),都不是登记流程能替它们做主的。
  12. FormDesignExtensionRegistry:45 引用的 extension-status 接口在代码里不存在(全仓 grep 只命中这条注释本身),所以「设计期提示扩展实现缺失」这道告警目前无后端支撑。
  13. 版本回滚只改草稿,线上版本不动,需用户再点发布才生效(rollback:151-154)。
  14. rollback 用 parseObjectQuietly 而不是 parseObject:后者遇到非法 JSON 会直接抛 JsonParseException,下面那句 null 判断就成了永远走不到的死代码,用户拿到的是一个 500 而不是「该版本快照已损坏」这样能看懂的提示。快照是几个月前写进库的一份文本,解析不出来是一种要正常处理的情况。
  15. code 不回滚(rollback:140):运行时与菜单都通过它引用本表单,改了会让已发布菜单失效。

刻意的取舍,不要「修好」 ​

现象为什么不修
schema_fingerprint 恒 NULL四条理由见 FormDesignDO:299-315;DDL 列注释描述的复核行为不存在
菜单由前端建、后端只给配置模块依赖单向 system → infra,infra 不能反向调 system
发布事件同步、失败回滚发布「发布成功但权限没登记」是本项目最贵的缺陷形态
trimOldVersions 物理删除快照是 mediumtext,软删 100 份以上会持续膨胀且没人会去清
unregister 不摘表名一张物理表可能被多张表单绑定,摘掉会关掉另一张活表单的行级过滤
FormResourceDescriptorBuilder 解析失败不抛发布不该被一份畸形 JSON 挡住,代价是「某一项静默缺失」

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

enlev-module-infra/src/test/java/.../formdesign/ 下 60 个测试文件。与本页直接相关的守卫:

测试守什么
FormDesignUnpublishTestmenu_id 置 NULL 必须走 wrapper(坑 #1 的回归守卫)
FormDesignPublishValidatorTest发布校验分组短路
FormDesignVersionServiceImplTest快照列齐全、trimOldVersions 上限
DatasetResourceServiceImplTest(system 侧,22 个 @Test)五步派生、tightenOnly 单向、applyDefaultEffectOnTypeUpgrade 只在升级时填
FormDesignPublishedListenerTest同步监听 + 异常回滚发布

system 侧全部纯 Mockito,无 DB 集成测试。

未证实 ​

  • FormDesignPublishValidator 2687 行只逐段读了 validate/inspect/inspect0 与缓存失效那几处,各组的具体判据没有逐条核对。
  • 「12 行悬挂数据」这个数字来自 FormDesignDO:248 与 unpublish:760 的注释,是历史快照,不是本次实测。
  • FormDesignApiImpl 的 11 个方法只核了 previewPublishMenu 一个。
  • 前端设计器(views/infra/formDesign/)本页没读,产物结构的口径以表单运行时为准。

相关页面 ​