采购审批到了经理这一步:明细子表里,数量允许改,单价必须只读。经理打开待办,单价输入框却是亮的------改完一点暂存,界面弹出:
「当前节点不允许编辑子表字段: fieldInput」
值班时很容易往三个方向猜:子表组件没把只读传下去?后端校验太严、只读列跟着整行快照提交就被拒?还是节点上的 formFieldPermissions 根本没解析到这列?
Forge Admin(开源后台,仓库见文末)把这件事拆成三层:列级可写(writable) 、行级动作(allowCreate / allowUpdate / allowDelete) 、保存白名单(writableFields) 。前端关键函数在 flow-field-permissions.js 的 applyChildTableFieldPermissions,单元格是否可改看 ChildTableEditor.isCellReadonly;后端保存走 BusinessFlowTaskChildPolicy.buildSavePermissions,再由 DynamicCrudTaskEditableCoordinator.filterTaskChildWriteData 过滤。本文依据仓库 main(HEAD c6013391c,2026-11-09 09:36 Asia/Shanghai)真实源码与提交;现场来自 2026-09-18 至 09-25 的踩坑与修复(9d010a009、bf873de0d、54765ce1f),未在本轮运行环境复现,推断处标明「按源码推演」,发布前请作者自行点验。
查重:和已发的会签 completionCondition、驳回直送、条件分支公共节点合并、跨租户短信、在线 DDL、门户入口权限都不是同一题。本篇只谈审批节点上的子表字段权限。
TL;DR
- 现象(历史) :子表某列配了只读,待办里仍能改,暂存报「当前节点不允许编辑子表字段: fieldInput」。这句文案已在
54765ce1f(2026-09-25 22:05 Asia/Shanghai)从 Java 删除,当前main不再抛。 - 根因 :前端未命中列权限时,曾用行级
allowUpdate对所有列调用enableChildField;带子表时formFieldPermissions是 v2 对象,按数组解析会丢掉fields,writableFields变空;后端又对行内每个 key 强制可写,整行快照里的只读列一并提交即拒。 - 改法 :未命中则保留只读标记,禁止
allowUpdate一刀切;对象形态先取fields再归一化;落库改为按白名单过滤。配置上,要改某列必须勾选该列「可编辑」,不要只开「允许修改行」。
全景:列级、行级、保存白名单
子表权限不是一个「只读」开关。Forge Admin 审批设计器把它拆开,再写进 BPMN 属性 flowable:formFieldPermissions:
| 层级 | 关键字段 | 管什么 |
|---|---|---|
| 列级 | readable / writable / required(兼容旧键 visible / editable) |
这一列能不能看、能不能改 |
| 行级 | allowCreate / allowUpdate / allowDelete |
已有行、新行能不能动 |
| 保存白名单 | TaskChildPermission.writableFields |
落库时哪些列允许写入 |
| 序列化形态 | 仅主表 v1 数组;带子表 v2 {version,fields,children};带数组明细 v3 |
解析必须按对象而不是一律当数组 |
设计器面板在 FormPermissionConfig.vue。按源码推演:未保存过的子表列默认 readable: true、writable: field.scope !== 'child',也就是子表列未勾选「可编辑」时默认只读 ,主表列默认可写。update() 写入新键时会同步旧键;关可见会连带关可编辑和必填。
同一张子表还可能同时出现关系键和带应用前缀的对象编码,例如 detail_ujpc 与 cgou_detail_ujpc。前端 childTableKeysAlias、后端 sameChildTableKey 用同一条规则:长键以 _ + 短键结尾则视为同一张表。别名对不上时,列权限会走「未命中」分支,后面两个坑都会被放大。

坑一:行级 allowUpdate 把只读列一刀切开写
现象
节点把 fieldInput(例如单价)配成只读,同表另一列 fieldInput2(例如数量)可写,并且开了「允许修改行」。待办里只读列仍能改。这是仓库在 2026-09-25 写入 code-copilot/memory/pitfalls/flow.md 的现场,不是本轮新复现。
根因(代码)
待办挂载在 useFlowTodo.part1.js 里,对 childrenConfig 调用 applyChildTableFieldPermissions,权限源按顺序取 taskFormInfo.formFieldPermissions、业务表单上下文、以及 pickFirstNonEmptyFieldPermissions(避免空数组把后面的节点权限挡住)。
修复前(54765ce1f 之前),字段权限未命中时,只要 child.allowUpdate === true,就对所有列 调用 enableChildField。同表另有可写列、或只开了行级修改时,只读列也会被打开,保存 payload 带着它去撞后端白名单。
当前逻辑已经改掉:
js
// forge-admin-ui/src/utils/flow-field-permissions.js
export function applyChildTableFieldPermissions(children = [], permissions = []) {
const list = collectPermissionFields(permissions)
return (Array.isArray(children) ? children : []).map((child) => {
const fields = (Array.isArray(child?.fields) ? child.fields : []).map((field) => {
const permission = list.find((item) => {
// 字段名 + childTableKeysAlias 匹配
})
if (permission)
return permission.writable === true ? enableChildField(field) : lockChildField(field)
// 未命中字段权限时保留后端/设计器标记。禁止用子表 allowUpdate 一刀切放开
return field
})
return {
...child,
fields,
allowUpdate: child?.allowUpdate === true || fields.some(item => item?.writable === true),
}
})
}
注意后半段:allowUpdate 仍会被重算------原值已是 true,或任一列 writable === true。这只影响「行能不能改」,不再打开未授权列。单测把这条红线写死了:子表 allowUpdate 不能把未授权字段一刀切开写、未命中字段权限时保留后端只读标记,不因 allowUpdate 放开。
单元格是否真能改,落在 ChildTableEditor.isCellReadonly:整表 props.readonly 一律只读;field.writable === true 时可改,即使行级 allowUpdate === false 也不能盖掉 ;writable === false 或 readonly/disabled 则只读;都没标时,已有行看 allowUpdate,新行看 allowCreate。
怎么改 / 怎么配
- 只让某几列可改时,必须在节点权限里明确勾选这些列的「可编辑」,不要只开子表「允许修改行」。
- 行级
allowUpdate解决的是「已有行能不能改」,列级writable才是白名单。 - 回放对不上列时,先核对子表键是不是
detail_ujpc/cgou_detail_ujpc这类别名,面板会把同一套字段收成一处(collapseAliasedChildFields)。
坑二:v2 对象被当数组解析,writableFields 变空
现象
同一句报错,不一定是前端把只读列打开了。按源码推演:只要保存侧拿到的 writableFields 是空的,行里任何一个业务列------包括只读快照列------都会被当成「不允许编辑」。
根因(代码)
仅主表时,serializeFlowFormPermissions 输出 v1 数组;带子表时输出 v2 对象 { version: 2, fields, children }。运行时任务表单上,formFieldPermissions 经常是一段 JSON 字符串。
BusinessFlowNodeFormNormalizer.normalizeFieldPermissions 的注释写得很直白:按数组解析会失败并丢掉 fields,暂存时子表 writableFields 为空,已提交的 fieldInput 会被拒绝。对象解析补丁写在 bf873de0d(2026-09-19 19:47 Asia/Shanghai),后来提取到 Normalizer(d3d78f4cd):
java
// 带子表时 formFieldPermissions 是 JSON 对象字符串(version/fields/children)。
// 按数组解析会失败并丢掉 fields,暂存时子表 writableFields 为空,已提交的 fieldInput 会被拒绝。
if (permissions instanceof String stringValue) {
String text = StringUtils.trimToEmpty(stringValue);
if (text.startsWith("{")) {
return normalizeFieldPermissions(readNestedObject(text));
}
}
单测 BusinessFlowNodeFormNormalizerTest.objectStringRetainsMainAndChildPermissions:输入 v2 JSON 字符串,断言主表 amount.writable=false、子表 items.qty.writable=true 都还在。BPMN 往返则由 user-task-parser-permissions.spec.js 覆盖「主子表 v2 权限能解析并写回,保留子表行操作权限」。
怎么改
对接或自写解析时,先看字符串是 { 还是 [:对象走 fields / children,不要一律 JSON.parse 成数组。白名单必须从归一化后的列权限投影,不能假定「有子表就等于整表可写」。
坑三:整行快照提交,后端按列拒绝,后来改成过滤
现象
子表编辑器提交的是整行快照。只读列即使你没改,也会出现在 payload 里。历史实现(9d010a009,2026-09-18 17:23 Asia/Shanghai 引入,当时在 DynamicCrudService.validateTaskChildRowPayload)遍历 row.keySet(),跳过内部字段后,不在 writableFields 就抛:
java
throw new BusinessException("当前节点不允许编辑子表字段: " + key);
这就是标题里的那句原文。按源码推演:坑一误开列、或坑二把白名单解析丢了,都会稳定打到这里。
根因与改法(代码)
真正落库本来就有过滤。54765ce1f(2026-09-25 22:05 Asia/Shanghai)删掉按列抛错,改成只检查行级动作(不允许删除/新增/修改子表行),行内只读快照不再拦截。逻辑后来提取到 DynamicCrudTaskEditableCoordinator(c90164384)。当前 filterTaskChildWriteData 只保留命中 writableFields 的列(含 camel/snake 别名),未授权列直接 continue:
java
String grantedField = resolveChildWritableField(relation, fieldRef.fieldName(), writableFields);
if (grantedField == null) {
continue;
}
保存白名单由 BusinessFlowTaskChildPolicy.buildSavePermissions 生成:若节点上有该子表的显式字段权限,只把 writable=true 的列放进 writableFields------发布态 / childrenConfig 上的 writable 不能反过来放开。单测 explicitPermissionsDriveSaveWhitelist 写的是:节点允许 quantity、禁止 price,即使 childrenConfig 把二者写反,保存白名单仍只有 quantity。
按源码推演,当前行为是:只读列改动会被丢掉,不再弹出那句历史报错;若过滤后主表和子表都没有可写变更,可能落到「未提交可编辑业务字段」。主表仍会按字段抛「当前节点不允许编辑字段: {field}」,子表整张未授权则抛「当前节点不允许编辑子表」。
对照:新旧键、AiForm,以及抄送必须整表只读
权限对象同时保留新键 readable/writable/required 和旧键 visible/editable。当前约定是先读新键,缺失再回退旧键 (normalizeFlowFieldPermission / 后端 Normalizer)。面板 update() 两组键一起写,单测「存在 visible/editable 旧字段时以 readable/writable 为准」,避免只改新键、回读却被旧键弹回。
AiForm 消费归一化之后的 editable:createFieldPermissionMap 会先走 normalizeFieldPermissions,把 writable 同步到 editable,再以 permission.editable === false 决定只读。按源码推演:若有调用方绕过归一化、只传 writable:false 且没有 editable,主表字段可能仍可编辑。这是消费约定,不是本轮复现。
抄送不要复用待办这条「列级可写」链。FlowCcServiceImpl 负责发送、我的抄送、已读、撤回和 getVisibleById,并不 解析 formFieldPermissions,也不把表单裁成只读。只读是这样串起来的:FlowCcController.getCcFormInfo 按流程实例可见性取表单(taskId/taskDefKey 传 null,避免误走任务参与人校验),前端 FlowReadonlyFormPanel 用 toReadonlyField 强制只读,并给 pickFirstNonEmptyFieldPermissions(..., { readOnly: true })、子表 ChildTableEditor 直接传 readonly。查看态必须整表只读,不能按待办的列级白名单放开。
还有一个容易混的默认值:设计器语义是「未配置权限 = 默认全量可写」(pitfall 101)。后端若把空数组当成无写权限,页面会全只读,保存报「当前节点没有可编辑业务字段」。当前 BusinessFlowTaskCommandCoordinator 在 permissions 为空时,会用表单字段目录生成默认权限后再保存。这和「子表列在面板上未勾选即可写默认 false」不是同一层,配的时候要分清。
自查清单(建议收藏)
| # | 检查项 | 通过标准 |
|---|---|---|
| 1 | 只要改某几列,有没有勾列级「可编辑」 | 不要只开「允许修改行」 |
| 2 | 行级 vs 列级 | allowUpdate 管行,writable 管列 |
| 3 | 导出 BPMN 的 formFieldPermissions |
带子表应是 v2 对象,含 fields 与 children |
| 4 | 子表键别名 | detail_ujpc 与 cgou_detail_ujpc 能对上同一张表 |
| 5 | 后端解析 | 以 { 开头的字符串按对象取 fields,不要当数组 |
| 6 | 保存行为(当前 main) | 只读快照列应被过滤,不应再抛「不允许编辑子表字段」 |
| 7 | 新旧键 | 以 readable/writable 为准,并同步 visible/editable |
| 8 | 抄送 / 已办 | 走只读面板,不要复用待办列级可写 |
| 9 | 单测 | flow-field-permissions-child.spec.js、BusinessFlowNodeFormNormalizerTest、BusinessFlowTaskChildPolicyTest 绿 |
跟我对照:从面板到落库
假设明细子表两列:数量可写、单价只读,已有行允许修改。
- 面板:数量勾选可编辑,单价不勾;行级打开
allowUpdate。 - writer 产出 v2:
fields里数量writable=true、单价writable=false,children里该表allowUpdate=true。 - 待办回放:
applyChildTableFieldPermissions只enableChildField数量,单价走lockChildField;allowUpdate可为 true,但不再放开单价。 - 保存:
buildSavePermissions的writableFields只有数量;filterTaskChildWriteData丢掉单价。即便前端误把单价改动带上来,当前 main 也不会再抛那句历史报错。
若停在 2026-09-25 修复之前:步骤 3 可能因 allowUpdate 一刀切把单价打开,步骤 4 再按行内每个 key 拒绝------标题里的现场就是这样来的。
系列说明与仓库
本文是 Forge Admin 审批设计器踩坑系列的一篇,只覆盖 审批节点上的子表字段权限(formFieldPermissions / applyChildTableFieldPermissions / 保存白名单)。前序可读:会签 completionCondition、驳回给发起人后的直送跳回、条件分支公共节点合并等。
- Gitee:gitee.com/ForgeLab/fo...
- GitHub:github.com/yaomindong1...
源码路径速查:
forge-admin-ui/src/utils/flow-field-permissions.jsforge-admin-ui/src/components/page-templates/ChildTableEditor.vueforge-admin-ui/src/components/flow-designer/panel/FormPermissionConfig.vueforge-admin-ui/src/utils/__tests__/flow-field-permissions-child.spec.jsforge-server/.../BusinessFlowNodeFormNormalizer.javaforge-server/.../BusinessFlowTaskChildPolicy.javaforge-server/.../DynamicCrudTaskEditableCoordinator.java
若这篇帮你对上了「只读列能改却存不了」的现场,麻烦点个赞和收藏。你在审批表单权限、抄送只读或子表回显上还卡过哪一题?评论区丢给我,下一篇优先写读者票最高的坑。