审批详情页高亮的那个节点,"名字"其实是一串 id:jeeflow 工作流引擎的四块数据来源

0 阅读1分钟

一、一页详情,四个接口,三种返回形状

一条审批单点开后,用户看到的是一页:上面是流程图(走过的线是绿的,当前那格是橙的),中间是表单,下面是一条时间线,右上角一行"当前处理人:张三、李四"。

看起来是一个页面的东西,在 jeeflow 里是四个接口拼出来的:

页面上的一块接口data 形状 · 要不要登录人
表单与流程定义detail对象 · 不要
流程图着色highLight对象四键 · 不要
审批时间线approvalRecord裸数组 · 不要
当前处理人getAssigneeTextData裸数组 · 不要
抄送我的(列表页,顺带)ccList分页壳 · 要,且还要权限码

(表里四个 action 的全称都带 processInstance/ 前缀。)前四个都只吃一个 id,第五个要认"我是谁"。这张表里三件事值得单独说:着色名单里装的是 id 不是名字、时间线包含进行中的任务、四个接口里只有一个需要登录人。

二、画布和着色是两个接口,不是一件事

流程图要画出来需要两份数据:图本身(节点、连线、坐标)和这份实例走到哪了。前者来自 detail:

data.put("jsonObject",
    def0 != null ? parseGraph(def0.getContent()) : null);

后者来自 highLight,它返回四个键(逐行摘自 JeeflowFacade.highLight):

data.put("activeNodeNames", activeNodeNames);
data.put("historyNodeNames", historyNodeNames);
data.put("historyEdgeNames", historyEdgeNames);
data.put("nodeProgress", nodeProgress);

前端只是把两份东西交给同一个设计器组件,一次传图、一次传着色数据:

// views/wf/process-instance/detail.vue
jsonObject.value = data.jsonObject || {};          // 来自 detail
// ...
processInstanceHighLight({ id: record.value?.id }).then((data) => {
  highLight.value = data;                          // 来自 highLight
});
// 模板里两枚一起喂给设计器
//   :high-light="highLight"   :assignee-text-data="assigneeTextData"

为什么要拆开? 因为两者变化节奏不同:图在流程定义不变时永远不变,着色每办理一次就变一次。合成一个接口意味着每次刷新状态都要把整张图的 JSON 再传一遍——复杂流程的 content 是几十 KB 的 LogicFlow JSON,而这个页面往往一次会话里要重开好几张单。

三、"节点名"里装的是 id,这是这一页最容易踩的坑

activeNodeNames、historyNodeNames、historyEdgeNames 三个键名都带 Names,直觉上里面该是"部门审批""总经理审批"这类中文节点名。

不是。 解析器在建模型时把 LogicFlow 的节点 id 写进了 name 字段:

// AbstractNodeParser.java
nodeModel.setName(lfNode.getId());  // 节点 name = 画布节点 id
tm.setName(edge.getId());           // 边同理

而引擎建任务行时用的就是这个 name:

task_name VARCHAR(100) NOT NULL COMMENT '任务名称编码',

所以这三份名单是给机器对齐图用的 id 集合,不是给人看的文案。设计器能直接着色,是因为它本来就按 id 索引画布上的节点;但要在页面上显示"部门审批(进行中)"这种文字,你得再拿 id 去图上反查显示名。

这条对做详情页的人有三个实际后果:

  1. 别拿这三份名单当文案渲染。要显示节点名,去 detail 的 jsonObject.nodes 里按 id 找 text.value。
  2. 别自己拼"节点名 → 状态"的映射。名单里是 id,任务行里的 task_name 也是 id,两边天然对齐;一旦中间掺进显示名就会错配(显示名可以重名,id 不行)。
  3. 数据库里那一列叫 task_name,注释写着"任务名称编码"——"编码"两个字就是这个意思:它是标识,不是标题。

至于 nodeProgress,它是这一页里唯一"给人看"的那份:按节点聚合,每个节点带成员列表,成员是 {id, name, done?, active?},done/active 只在命中时才出键,会签节点额外带 type(PARALLEL / SEQUENTIAL)。姓名走 IUserProvider SPI 解析,查不到时给空串——所以前端必须准备"只有 id"的降级显示,不能假设 name 一定有值。

三份名单里装的是 id

四、审批记录是任务历史,不是"已办结清单"

时间线那块吃 approvalRecord,它只有一句话的实现:

List<ProcessTask> history = repository.findHistoryTasks(instanceId);

而那条 SQL 里没有任何状态条件:

SELECT * FROM wf_process_task
WHERE process_instance_id = ?
ORDER BY create_time ASC

⇒ 进行中的任务、已撤回的任务都在这份记录里。这不是遗漏,是这一格的定义:它是"这条单子身上发生过什么"的流水,不是"我批过什么"的清单(后者是上一篇里"我的已办"那格的职责,那边要的是 task_state <> 10)。

出口是裸数组(data 直接就是行列表,不套分页壳),每行八个键:

vo.put("taskName", t.getTaskName());
vo.put("displayName", t.getDisplayName());
vo.put("taskType", ... .getCode());
vo.put("performType", ... .getCode());
vo.put("taskState", t.getTaskState());
vo.put("operator", t.getActorId());
vo.put("finishTime", fmtTime(t.getFinishTime()));
vo.put("ext", t.getVariables() != null
        ? t.getVariables() : new LinkedHashMap<>());

请注意这份清单里没有的两样东西:

  • 没有 id。八键里没有任务主键。所以前端做时间线时不能拿 item.id 当列表 key(得自己用 taskName + 序号拼一个)。
  • 没有 submitType,也没有姓名。"他点了同意还是拒绝"、"这个人叫什么",引擎都不给。

这两样在前端从 ext 里取——ext 就是这一行任务的变量 JSON,办理时写进去的东西都在这儿:

// views/wf/process-instance/approval-record.vue
<span>{{ item.ext?.u_realName }}</span>
<ApiDict :value="item.ext?.submitType"
         code="wf_process_submit_type" />

姓名是引擎在执行任务时顺手写进变量的 u_realName,提交类型是 submitType;标签文案走后端枚举字典 wf_process_submit_type,不是前端硬编码的一份映射。

为什么把渲染留在前端? 因为"同意/拒绝"这套文案是要跟着语言和项目改的(我们另一套前端里就是硬编码 map),而引擎侧给 code 才能保证八种语言、十三套框架接同一个数字。ext 这个出口是任务变量的唯一对外通道,所以业务想往时间线上加"金额""紧急程度"这类字段,不需要动引擎——办理时带进 tf_* 参数,它就出现在 ext 里。

审批记录:引擎给八个键,文案前端拼

五、"当前处理人"是另一种人,一人一行

getAssigneeTextData 负责右上角那一行。它的实现说明了两件事:

boolean includeNodeName =
        !Boolean.FALSE.equals(args.get("includeNodeName"));
List<ProcessTask> doing = repository.findDoingTasks(instanceId, null);
for (ProcessTask t : doing) {
    List<String> actors = repository.findTaskActors(t.getTaskId());
    for (String actor : actors) {
        item.put("value", actor);
        item.put("label", includeNodeName
                ? t.getDisplayName() + ":" + actor : actor);
    }
}
  • 只取进行中的任务(findDoingTasks ⇒ task_state = 10),所以它天然是"现在卡在谁手上",不掺历史。
  • 一个参与人一行,不按节点合并。三人会签就是三行,前端要合并成一行自己 join。
  • value 是参与者用户 id,label 是"节点显示名:用户id"。includeNodeName 默认开,只有显式传布尔 false 才关。

这一格和上一格的区别,正好是那对老问题——"该谁做"和"谁做了"是两个问题:

  • 该他办:getAssigneeTextData 读参与者表 wf_process_task_actor,只取进行中的任务,一人一行;
  • 他办完:approvalRecord 的 operator 读任务表自己那一列,一步一个值。

所以详情页上"当前处理人"和"审批记录里的人"是两个来源,别指望一个接口给全。

两种"处理人"各吃哪张表

六、四个接口里只有一个要登录人,这不是疏忽

highLight / approvalRecord / getAssigneeTextData 都只吃 id,而且引擎内置的权限映射把它们列在放行清单里:

NO_PERM_ACTIONS.addAll(Arrays.asList(
    "processInstance/detail", "processInstance/highLight",
    "processInstance/approvalRecord",
    "processInstance/getAssigneeTextData", "processInstance/bizData",
    "processTask/detail", "processTask/addCandidate",
    "processTask/latest",
    "processInstance/stats/overview", "processInstance/stats/trend",
    "processInstance/stats/group"));

引擎不依赖任何鉴权框架,只通过 SPI 提供"action → 权限码"的映射元数据,校验发生在集成层(框架壳里那句 StpUtil.checkPermissionOr(codes),超管直接放行)。放行不等于没边界——它意味着边界换了一层:

  • 这三个接口是实例级只读:拿到 id 就能看这条单子的图和记录,谁登录都一样。
  • 于是"谁能拿到这个 id"就成了真正的关口,而那一关在列表侧——待办、已办、我发起的、抄送我的四类入口的归属过滤(那是另一篇的四个"我的"菜单各查哪张表的事)。

ccList 是这批里唯一的例外:它要权限码 wf:processInstance:ccList,并且必须带 operator,因为它按"我"来过滤:

query.add("cc.actor_id", "EQ", userId);

它的行源是流程实例表,抄送表只出条件不出字段(cc.state 在筛选白名单里,可以拿 m_cc_EQ_state = 0 筛未读,但不在出口字段里)。所以出口里那个 operator 是发起人,不是"抄送给我的人"——这一格我们踩过一次,规则后来钉成三要件:行源必须是实例、operator 必须是发起人、主键键名必须是 id;想知道"这条是谁发给我的",取 cc 行的 create_user,不许占用 operator 这个键名。

一句话收这段:做详情页接口时,先决定它是"实例级只读"还是"我的"。前者只要 id、可以放行;后者必须显式带登录人,且缺人时返回空而不是返回全部。

七、四块内容各自重取,别把它们焊死

前端的接线方式也值得说,因为它决定了"办理完一次,页面要不要整页刷新"。

vben5-wf 里这四块不是一次批量取回:详情抽屉打开时三枚并发发出、互不依赖(detail / highLight / getAssigneeTextData),而审批记录根本不在详情抽屉里发——它在自己的 tab 组件里:

// views/wf/process-instance/approval-record.vue
const requestData = () => {
  if (!props.data?.id) return;
  approvalRecord({ id: props.data.id })
    .then((res) => { dataSource.value = res; });
};
onMounted(() => { requestData(); });
watch(() => props.data.id, requestData);

watch 那一行是关键:换一张单子,时间线自己重取,画布不用重画。同理,办理完只想刷新着色,就只重打 highLight 那一枚。

这不是"前端偷懒没合并接口",而是四类数据的变化频率与失败面天然不同(按上面那四块顺序对):

块变化频率失败了会怎样
图与表单定义不改就不变画布空,页面没意义 ⇒ 必须拿到
着色每次办理图能看,线不亮 ⇒ 可容忍
审批流水每次办理只影响那个 tab
当前处理人每次办理少一行字

接口分开,前端才有"按块降级"的选择;合成一个聚合接口,最轻的那块会拖着最重的那块一起失败。

四块内容 × 变化频率 × 失败面

八、拿去自查:做审批详情页该问的六个问题

  1. 图和状态是两份数据还是一个接口? 定义几乎不变、状态每办一次都变——分开传,别让每次刷新都重传整张图。
  2. 那份"节点名单"里装的是 id 还是名字? 如果是 id,画布能直接着色,但任何要显示给人看的地方都得反查一次;如果混着两种,重名节点必然错配。
  3. "审批记录"要不要包含进行中和已撤回的? 先定义清楚它是"流水"还是"办结清单",再决定要不要状态过滤——这两句话在需求评审时经常被当成同一件事。
  4. 姓名和"同意/拒绝"标签在哪一侧渲染? 引擎给 code 和原始 id,文案在前端或字典。定了就别两头做,否则换一套前端就对不上。
  5. 这一页里哪些接口是"我的"语义? 只吃 id 的是实例级只读,边界在入口列表;带 operator 的必须缺人即空页,不能退化成"这条条件不加"。
  6. 换一张单子时,哪几块要重取? 用 watch 挂实例 id 让每块自己重取,比整页 reload 省一次画布重建,也比手工在提交回调里逐个刷新少漏一处。

结语

这一页看上去是"一个详情接口"的事,拆开是四份数据、三种返回形状、两类归属语义,加上一堆"引擎只给 code,文案在前端"的分工。这种分工的代价是每个前端都要多写几行取值逻辑,好处是同一份后端能同时喂两套完全不同的前端、八种语言的引擎实现给出同一批键名。

如果你的系统里也有这么一页,值得先画那张"哪块内容吃哪个接口"的表——它顺手就把变更频率、失败面和权限语义一起定了。

参考资料