Skip to content

09 · 业务数据通用入库(persist 契约)

版本:1.8.0(起,1.8.0 同步演进) 定位:引擎无关的动态表写入组件 + 引擎适配的入库拦截器,四语言契约一致。 集成方通过配置声明「流程数据写入 X 表」——ARCHIVE 模式(缺省)流程结束同意后落库一次; SYNC 模式(1.8.0)提交申请即入库、任务节点推进更新、结束定稿最终状态,不管成功失败都入库

1. 组件分层

流程设计器配置(Java postInterceptors 类名 / 其余语言全局注册拦截器)


PersistPostInterceptor(引擎适配层,~100 行/语言)
  时机判断 / f_ 字段提取 / 表名解析 / 幂等键 / 流程上下文字段


DynamicTableWriter(引擎无关核心,~150 行/语言)
  列过滤 → 参数化 INSERT → 系统字段 → 幂等 exists
  • DynamicTableWriter 不依赖工作流引擎:任何「给表名 + 字段 Map 安全写业务表」的需求都可直接使用
  • PersistPostInterceptor 只做流程语义适配:把「何时写、写什么、写哪张表」翻译成组件调用

2. DynamicTableWriter 接口(四语言契约)

方法语义
filterColumns(tableName, columns) → List<String>按目标表过滤列(表结构探测),返回表内实际存在的列
insert(tableName, data) → Object参数化 INSERT(列过滤后落库),返回生成主键
update(tableName, data, whereColumn, whereValue) → int参数化 UPDATE(列过滤组装 SET;条件列排除防注入),返回受影响行数(1.8.0,SYNC 模式)
exists(tableName, bizKey, bizKeyValue) → boolean幂等检查:指定业务键(如 process_instance_id)是否已存在
fillSystemFields(data, isInsert)按配置列名填充系统字段(未配置的列跳过)

语言签名对照:

java
// Java — com.mldong.jeeflow.persist.DynamicTableWriter(jeeflow-persist 模块)
public interface DynamicTableWriter {
    List<String> filterColumns(String tableName, List<String> columns);
    Object insert(String tableName, Map<String, Object> data);
    default int update(String tableName, Map<String, Object> data, String whereColumn, Object whereValue) {
        throw new UnsupportedOperationException("当前 writer 不支持 update(SYNC 同步演进需要)");
    }
    boolean exists(String tableName, String bizKey, Object bizKeyValue);
    void fillSystemFields(Map<String, Object> data, boolean insert);
}
go
// Go — github.com/mldong/jeeflow-go/persist(persist 子包)
type DynamicTableWriter interface {
    FilterColumns(tableName string, columns []string) ([]string, error)
    Insert(tableName string, data map[string]interface{}) (interface{}, error)
    Update(tableName string, data map[string]interface{}, whereColumn string, whereValue interface{}) (int64, error)
    Exists(tableName, bizKey string, bizKeyValue interface{}) (bool, error)
    FillSystemFields(data map[string]interface{}, isInsert bool)
}
python
# Python — jeeflow.persist(persist.py,随主包发布)
class DynamicTableWriter(ABC):
    def filter_columns(self, table_name: str, columns: Sequence[str]) -> list[str]: ...
    def insert(self, table_name: str, data: dict[str, Any]) -> Any: ...
    def update(self, table_name: str, data: dict[str, Any], where_column: str, where_value: Any) -> int: ...
    def exists(self, table_name: str, biz_key: str, biz_key_value: Any) -> bool: ...
    def fill_system_fields(self, data: dict[str, Any], is_insert: bool) -> None: ...
ts
// Node — @mldong/jeeflow(src/persist.ts,随主包发布)
export interface DynamicTableWriter {
  filterColumns(tableName: string, columns: string[]): string[] | Promise<string[]>
  insert(tableName: string, data: Record<string, unknown>): unknown | Promise<unknown>
  update(tableName: string, data: Record<string, unknown>, whereColumn: string, whereValue: unknown): number | Promise<number>
  exists(tableName: string, bizKey: string, bizKeyValue: unknown): boolean | Promise<boolean>
  fillSystemFields(data: Record<string, unknown>, isInsert: boolean): void
}

3. 默认实现(JdbcDynamicTableWriter)

语言模块/包数据源方言处理
Javajeeflow-persist(Maven Central)DataSourceinformation_schema.columnsUPPER() 比较兼容 H2 大写存储
Gopersist/ 子包(go get 同 module)*sql.DB驱动类型含 sqlite 走 PRAGMA table_info,其余 information_schema
Pythonjeeflow.persist(PyPI)DB-API 2.0 连接sqlite3.Connection 走 PRAGMA,其余 information_schema
Node@mldong/jeeflow(npm)node:sqlite DatabaseSyncPRAGMA table_info(内置零依赖);MySQL/PG 自实现接口

通用行为(四语言一致):

  • 表名安全sys_ 前缀拒绝(框架保留);非法字符(非字母数字下划线)拒绝
  • 列过滤:按目标表实际列过滤,多传的字段自动丢弃
  • 参数化 INSERT:PreparedStatement/占位符,值注入不生效
  • 列匹配(1.6.4):默认宽松——驼峰↔下划线归一匹配(表单字段 companyName ↔ 表列 company_name,写入保持表列原名);setStrictColumnMatch(true) 显式开启严格模式(忽略大小写精确匹配,少数特殊场景逃生门)。exists 的 bizKey 参数(下划线)不受影响
  • 系统字段create_time / create_user / update_time / update_user / is_deleted(可配置列名,null 禁用),插入填全量,更新只填 update 组
  • 用户列默认值(1.6.3)create_user/update_user 优先取 apply_user_id(= 流程 operator)——多数框架业务表该列是 BIGINT 存 userId,开箱即用;无 operator 的场景(引擎无关直接调用 writer)回落可配置默认值(setDefaultUserValue,缺省 "system"
  • 主键生成(1.6.5):自增检测(MySQL EXTRA=auto_increment;PG IS_IDENTITY/column_default nextval;H2 IS_IDENTITY;SQLite INTEGER PRIMARY KEY=rowid 别名)+ 可配置生成器 setPrimaryKeyGenerator(表名→主键值)(雪花等)——data 已有主键值用之 → 自增不生成 → 非自增未配置生成器抛清晰错误(替代误导性 SQL 异常);exists 幂等不受影响
  • schema 限定(1.6.6):information_schema 探测限定当前 schema(MySQL DATABASE();PG/H2 CURRENT_SCHEMA())——多库同名表不再列重复;主键约束 JOIN 同样限定同 schema
  • 表不存在:探测失败即显性报错(配置错误快速失败,不静默丢数据)

4. PersistPostInterceptor 语义契约

4.0 流程定义配置字段(集成方只声明这两个,挂载按引擎架构)

行为契约四语言统一:持久化由流程定义 JSON 顶层的两个字段驱动(与 name/nodes 同层):

字段取值语义
relTableName表名业务表名——拦截器把流程数据写入哪张表;缺省回落流程 name(流程唯一编码 = 业务表名约定)。表名解析出来后表不存在 = 配置错误显性报错
persistModeARCHIVE / SYNC缺省 ARCHIVE持久化模式——ARCHIVE:流程结束且同意时落库一次(归档快照);SYNC:提交申请即入库 → 任务节点推进更新 → 结束定稿,全程留痕(见 4.1/4.2)。SYNC 值一律回落 ARCHIVE(未知值不报错,保持向后兼容)
json
{
  "name": "leave",
  "type": "approval",
  "relTableName": "biz_leave",
  "persistMode": "SYNC",
  "nodes": []
}

拦截器挂载机制(1.8.4 起四语言统一「定义级声明」)

语言定义级挂载(1.8.4 起)引擎级挂载(向后兼容)
Java流程定义顶层 postInterceptors 声明拦截器类名,引擎反射实例化(模型级,历史沿用)ServiceContext 注册(创建任务等通道)
Go / Python / Node流程定义顶层 postInterceptors 声明名字,引擎按名从注册表解析(InterceptorRegistry / interceptor_registry / interceptorRegistry引擎级 Extensions.Interceptors 列表(未声明的流程仍触发)

语义(1.8.4 起四语言一致):流程定义顶层 postInterceptors 声明的拦截器只对该流程生效—— 未声明的流程不触发定义级拦截器;引擎级列表挂载的拦截器对所有流程生效(向后兼容现有集成)。 集成方按流程隔离持久化行为:声明了 persist 的流程落库,未声明的不落库。

解析链:Java——LfModel.postInterceptorsModelParserProcessModel(反射实例化); Go/Python/Node——拦截器签名不含流程模型,引擎经仓储加载定义 content 解析顶层 postInterceptors(按 defineId 缓存),按名从注册表取实例。

4.1 ARCHIVE 时机(缺省)

流程正常结束时入库:

条件判定
节点结束节点(EndModel / snaker:end
实例状态FINISHED(Go/Python/Node 为 Done
submitTypeAGREE(1)——不同意/退回不入库

引擎对齐(1.6.2 起):结束节点统一走节点执行链(Go/Python/Node 任务完成路径不再内联 finish),保证后置拦截器在流程结束时完整触发。

4.2 SYNC 时机(1.8.0 同步演进)

按节点类型三态路由(exists 先查后插/更):

节点动作写入内容
开始节点(发起)INSERT 全量f_ 全部表单字段 + 上下文 + 系统字段(插入组)
任务节点(推进)UPDATEf_ 按节点字段权限过滤 + tf_ 冗余(有列则写)+ 状态字段=DOING(10)
结束节点(定稿)UPDATE仅状态字段=实例最终态(FINISHED=20 / REJECT=45)+ 上下文

字段权限(SYNC 任务节点,vben5-wf 机制):任务节点 properties.field 声明该节点对表单字段的编辑权限。

键格式(1.8.1 起双格式兼容)

键格式示例说明
PERMISSION_f_{表单字段全名}"PERMISSION_f_title": 1前端 vben5-wf 设计器约定(优先匹配)
PERMISSION_{去前缀名}"PERMISSION_title": 1后端 1.8.0 首版格式(兼容)

1.8.0 首版只匹配 PERMISSION_{去前缀名},与前端 PERMISSION_f_ 约定不一致导致只读/隐藏失效 (可篡改业务字段)——1.8.1 起两种都匹配,前端约定格式优先

语义持久化
缺省可编辑更新
1只读不更新
2可编辑更新
3隐藏不更新

非任务节点(结束/网关)不覆盖业务字段——避免全量覆盖任务节点的只读/隐藏限制(1.8.0 起)。

权限语义(1.8.2 起强化):节点声明的权限管控的是**「办理时可提交的字段」—— 被声明只读/隐藏的 f_ 字段,办理提交的值在引擎入口(任务完成入变量前)即被过滤**, 不并入流程变量。因此被拒值无法经流程变量落到下游节点写入——即使下游节点无权限声明 (全量更新),也写不进去(变量里没有该值)。上游只读声明不可被绕过。

1.8.0/1.8.1 只有拦截器写入侧过滤:被拒值先入变量(completeTask 无条件 putAll), 下游无权限节点完成时全量提取写入业务表——上游只读可被绕过(缺陷)。 1.8.2 起引擎办理入口过滤filterFieldByPerm:值非 EDIT(2) 的 f_ 字段剔除), 拦截器写入侧过滤保留为双保险。

状态字段(SYNC):值 = 实例状态码(DOING=10 / FINISHED=20 / REJECT=45 / PENDING=50), 列名优先 {节点ID}_{状态码}(如 task1_10),无该列回落 {节点ID}(如 task1)——列探测过滤,表无对应列则跳过。

tf_ 冗余(SYNC):任务节点提交的 tf_ 前缀变量(如 tf_opinion 审批意见)去前缀冗余到业务表对应列(列过滤由 writer 做,无列则丢弃)。

提交时机:任务节点写 DOING(任务推进状态)——任务节点后置拦截器在流转链(结束节点定稿)之后触发,实例状态已被结束节点改为最终态,因此任务节点统一写 DOING;结束节点写实例最终状态。

4.3 字段契约

来源规则
表单字段实例 Variables 中 f_ 前缀字段,去前缀(如 f_titletitle
流程上下文process_instance_id(实例 ID)/ apply_user_id(发起人)/ apply_dept_idu_deptId),蛇形列名约定
系统字段writer 通用字段(见 §3),插入/更新模式

4.4 表名解析

  1. 流程定义顶层 relTableNameProcessModel.relTableName
  2. 缺省回落流程 name(流程唯一编码 = 业务表名约定,与元数据表单同约定)

4.5 幂等(双层防护,1.6.3 起;1.8.0 改节点级)

机制作用域说明
① 同链内存标记__persist_executed_{instanceId}_{节点ID} 写入执行链共享状态(Java execution.args;Go/Python/Node inst.variables同一次执行链每个节点触发一次(任务推进更新 + 结束定稿是不同节点,都要生效),同节点不重复
② 数据库幂等exists(process_instance_id) 先查后插/更跨请求/重启兜底(独立连接、事务提交后生效)

1.8.0 把标记从「实例级」改为「节点级」:SYNC 下任务节点与结束节点都要各自生效, 实例级标记会跳过任务节点的推进更新(任务节点 execPost 在结束节点之后触发)。 ARCHIVE 模式行为不变(仅结束节点落库,节点级标记同样只放行一次)。

4.6 静默跳过 vs 显性报错

场景行为
非结束节点(ARCHIVE)/ 非同意 / 实例非完成态静默跳过
writer 未注入静默跳过(挂载了但没配组件,不拦截流程)
未配置表名(relTableName 与流程 name 皆空)静默跳过
表名解析出来了但表不存在显性报错(配置错误快速失败)

5. 挂载方式(四语言)

java
// Java——模型级挂载(引擎零改动)
// 流程定义 content 顶层:
//   "postInterceptors": "com.mldong.jeeflow.persist.interceptor.PersistPostInterceptor",
//   "relTableName": "biz_leave"
// 启动时注册 writer 到引擎上下文:
ServiceContext.put("dynamicTableWriter", new JdbcDynamicTableWriter(dataSource));
go
// Go——全局拦截器 + DefineLoader(引擎 Extensions)
ic := persist.NewPersistPostInterceptor(writer, repo.FindDefineByID)
eng.SetExtensions(&engine.Extensions{Interceptors: []engine.FlowInterceptor{ic}})
python
# Python——全局拦截器 + loader
ic = PersistPostInterceptor(writer=writer, loader=repo.find_define_by_id)
eng.set_extensions(EngineExtensions(interceptors=[ic]))
ts
// Node——全局拦截器 + loader
const ic = new PersistPostInterceptor(writer, async id => repo.findDefineById(id))
engine.setExtensions({ interceptors: [ic] })

说明:行为四语言统一——拦截器都只对声明了 relTableName 的流程生效(未声明自动跳过), 触发时机与写入语义一致。挂载机制按引擎架构:Java 用模型级 postInterceptors (反射实例化,无参构造后从 ServiceContext 按类型自取 writer,历史沿用); Go/Python/Node 挂在引擎全局 Extensions,拦截器内部按「节点类型 + 实例状态 + 提交类型」 过滤。Go/Python/Node 因拦截器签名不含流程模型,表名/模式经 DefineLoader (透传 findDefineById)从流程定义 content 解析同一批字段。

6. 合规测试矩阵

四语言 persist 用例(writer + 拦截器集成,逻辑一一对应):

用例断言
流程结束同意 → 落库(ARCHIVE)f_ 去前缀 / 系统字段 / 流程上下文全量断言
拒绝(submitType=2,ARCHIVE)不入库
不同意(submitType=0,ARCHIVE)不入库
writer 未注入静默跳过
表不存在显性报错
幂等(跨请求)exists 先查后插,同实例重复仅 1 条
幂等(同链)任务节点 + 结束节点各触发一次拦截器 → 仅 1 条
用户列默认值create_user 取 operator;无 operator 回落配置默认值
BIGINT 用户列create_user/update_user 为 BIGINT,operator 数字时插入不报类型错误
SYNC 全链路(1.8.0)发起 INSERT → apply 推进 UPDATE(状态=10)→ task1 UPDATE(只读不更新/可编辑更新/tf_ 冗余/状态=10)→ 结束定稿(状态=20),先插后更仅 1 条
SYNC 驳回(1.8.0)结束节点定稿 REJECT=45,数据不丢(发起已入库)
SYNC 字段权限(1.8.0/1.8.1)双键格式(前端 PERMISSION_f_title=1 只读 + 兼容 PERMISSION_amount=2 可编辑)均生效 / 缺省全量
writer 全字段插入数据 + 系统字段
缺列过滤目标表不存在的列丢弃
类型 null显式 null 正常入库
防注入值含 SQL 片段不生效、表结构不受破坏
宽松列匹配驼峰 key 落库为下划线表列(companyName → company_name),写入用表列原名
严格列匹配显式开启后驼峰不再匹配
非自增主键生成雪花/应用生成主键表配生成器插入成功;data 已含主键用之
未配置生成器非自增主键表缺生成器 → 清晰报错(表/主键列名+配置指引)
多 schema 同名表其他 schema 同名表列不混入探测/插入
sys_ 前缀 / 非法字符表名拒绝

7. 版本与发布

语言发布形态版本
Javajeeflow-persist 子模块(Maven Central,随主版本)1.8.0
Gopersist/ 子包(go get 同 module)1.8.0
Pythonjeeflow.persist 模块(PyPI,随主包)1.8.0
Nodesrc/persist.ts(npm,随主包)1.8.0

jeeflow · 轻量级多语言工作流引擎