子表只读列界面能改,保存却报不允许编辑:Forge Admin 把字段权限落到子表列

采购审批到了经理这一步:明细子表里,数量允许改,单价必须只读。经理打开待办,单价输入框却是亮的------改完一点暂存,界面弹出:

「当前节点不允许编辑子表字段: 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

  1. 现象(历史) :子表某列配了只读,待办里仍能改,暂存报「当前节点不允许编辑子表字段: fieldInput」。这句文案已在 54765ce1f(2026-09-25 22:05 Asia/Shanghai)从 Java 删除,当前 main 不再抛。
  2. 根因 :前端未命中列权限时,曾用行级 allowUpdate 对所有列调用 enableChildField;带子表时 formFieldPermissions 是 v2 对象,按数组解析会丢掉 fields,writableFields 变空;后端又对行内每个 key 强制可写,整行快照里的只读列一并提交即拒。
  3. 改法 :未命中则保留只读标记,禁止 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。

怎么改 / 怎么配

  1. 只让某几列可改时,必须在节点权限里明确勾选这些列的「可编辑」,不要只开子表「允许修改行」。
  2. 行级 allowUpdate 解决的是「已有行能不能改」,列级 writable 才是白名单。
  3. 回放对不上列时,先核对子表键是不是 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 绿

跟我对照:从面板到落库

假设明细子表两列:数量可写、单价只读,已有行允许修改。

  1. 面板:数量勾选可编辑,单价不勾;行级打开 allowUpdate。
  2. writer 产出 v2:fields 里数量 writable=true、单价 writable=false,children 里该表 allowUpdate=true。
  3. 待办回放:applyChildTableFieldPermissions 只 enableChildField 数量,单价走 lockChildField;allowUpdate 可为 true,但不再放开单价。
  4. 保存:buildSavePermissions 的 writableFields 只有数量;filterTaskChildWriteData 丢掉单价。即便前端误把单价改动带上来,当前 main 也不会再抛那句历史报错。

若停在 2026-09-25 修复之前:步骤 3 可能因 allowUpdate 一刀切把单价打开,步骤 4 再按行内每个 key 拒绝------标题里的现场就是这样来的。

系列说明与仓库

本文是 Forge Admin 审批设计器踩坑系列的一篇,只覆盖 审批节点上的子表字段权限(formFieldPermissions / applyChildTableFieldPermissions / 保存白名单)。前序可读:会签 completionCondition、驳回给发起人后的直送跳回、条件分支公共节点合并等。

源码路径速查:

  • forge-admin-ui/src/utils/flow-field-permissions.js
  • forge-admin-ui/src/components/page-templates/ChildTableEditor.vue
  • forge-admin-ui/src/components/flow-designer/panel/FormPermissionConfig.vue
  • forge-admin-ui/src/utils/__tests__/flow-field-permissions-child.spec.js
  • forge-server/.../BusinessFlowNodeFormNormalizer.java
  • forge-server/.../BusinessFlowTaskChildPolicy.java
  • forge-server/.../DynamicCrudTaskEditableCoordinator.java

若这篇帮你对上了「只读列能改却存不了」的现场,麻烦点个赞和收藏。你在审批表单权限、抄送只读或子表回显上还卡过哪一题?评论区丢给我,下一篇优先写读者票最高的坑。

相关推荐
杨凯凡34 分钟前
MySQL 死锁怎么排查?InnoDB 锁等待与 Spring Boot 整事务重试实战
数据库·spring boot·mysql
RuoyiOffice12 小时前
SpringBoot+Vue3 凭证生命周期设计:审核、过账、冲销与反过账怎样保持账目一致
java·spring boot·事务·springboot3·财务系统·会计凭证·ruoyi office
RuoyiOffice13 小时前
KubeSphere 容器云实践:从管理 K8s 到建设企业 PaaS,还需要哪些能力
spring boot·kubernetes·kubesphere·jenkins·paas·微服务部署·ruoyi office
软件技术NINI20 小时前
基于SpringBoot和Vue的新能源汽车销售推荐系统
java·vue.js·spring boot·后端·汽车·毕业设计·毕设
Flynn Liang20 小时前
LangChain4j 生产级工程骨架:12 个模块 + Docker 一键启动
java·spring boot·langchain·rag·langchain4j
RuoyiOffice21 小时前
固定资产维修管理:从报修、审批到办结,原资产卡怎么回查?
spring boot·固定资产·ruoyi office·资产维修·维修审批·维修办结·资产履历
招风的黑耳21 小时前
SpringBoot+Vue+MySQL 民宿在线预定平台 完整可运行版(源码+文档)
vue.js·spring boot·mysql
Java码农也是农1 天前
Spring 事务详解:@Transactional 如何生效、何时回滚、传播行为怎么选
java·spring boot·spring·事务
学长毕设1 天前
基于SpringBoot的游乐园管理系统-附源码
java·spring boot·后端