Appearance
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)
| 语言 | 模块/包 | 数据源 | 方言处理 |
|---|---|---|---|
| Java | jeeflow-persist(Maven Central) | DataSource | information_schema.columns,UPPER() 比较兼容 H2 大写存储 |
| Go | persist/ 子包(go get 同 module) | *sql.DB | 驱动类型含 sqlite 走 PRAGMA table_info,其余 information_schema |
| Python | jeeflow.persist(PyPI) | DB-API 2.0 连接 | sqlite3.Connection 走 PRAGMA,其余 information_schema |
| Node | @mldong/jeeflow(npm) | node:sqlite DatabaseSync | PRAGMA 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;PGIS_IDENTITY/column_default nextval;H2IS_IDENTITY;SQLiteINTEGER PRIMARY KEY=rowid 别名)+ 可配置生成器setPrimaryKeyGenerator(表名→主键值)(雪花等)——data 已有主键值用之 → 自增不生成 → 非自增未配置生成器抛清晰错误(替代误导性 SQL 异常);exists幂等不受影响 - schema 限定(1.6.6):information_schema 探测限定当前 schema(MySQL
DATABASE();PG/H2CURRENT_SCHEMA())——多库同名表不再列重复;主键约束 JOIN 同样限定同 schema - 表不存在:探测失败即显性报错(配置错误快速失败,不静默丢数据)
4. PersistPostInterceptor 语义契约
4.0 流程定义配置字段(集成方只声明这两个,挂载按引擎架构)
行为契约四语言统一:持久化由流程定义 JSON 顶层的两个字段驱动(与 name/nodes 同层):
| 字段 | 取值 | 语义 |
|---|---|---|
relTableName | 表名 | 业务表名——拦截器把流程数据写入哪张表;缺省回落流程 name(流程唯一编码 = 业务表名约定)。表名解析出来后表不存在 = 配置错误显性报错 |
persistMode | ARCHIVE / 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.postInterceptors → ModelParser → ProcessModel(反射实例化); Go/Python/Node——拦截器签名不含流程模型,引擎经仓储加载定义 content 解析顶层 postInterceptors(按 defineId 缓存),按名从注册表取实例。
4.1 ARCHIVE 时机(缺省)
仅流程正常结束时入库:
| 条件 | 判定 |
|---|---|
| 节点 | 结束节点(EndModel / snaker:end) |
| 实例状态 | FINISHED(Go/Python/Node 为 Done) |
| submitType | AGREE(1)——不同意/退回不入库 |
引擎对齐(1.6.2 起):结束节点统一走节点执行链(Go/Python/Node 任务完成路径不再内联 finish),保证后置拦截器在流程结束时完整触发。
4.2 SYNC 时机(1.8.0 同步演进)
按节点类型三态路由(exists 先查后插/更):
| 节点 | 动作 | 写入内容 |
|---|---|---|
| 开始节点(发起) | INSERT 全量 | f_ 全部表单字段 + 上下文 + 系统字段(插入组) |
| 任务节点(推进) | UPDATE | f_ 按节点字段权限过滤 + 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_title → title) |
| 流程上下文 | process_instance_id(实例 ID)/ apply_user_id(发起人)/ apply_dept_id(u_deptId),蛇形列名约定 |
| 系统字段 | writer 通用字段(见 §3),插入/更新模式 |
4.4 表名解析
- 流程定义顶层
relTableName(ProcessModel.relTableName) - 缺省回落流程 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. 版本与发布
| 语言 | 发布形态 | 版本 |
|---|---|---|
| Java | jeeflow-persist 子模块(Maven Central,随主版本) | 1.8.0 |
| Go | persist/ 子包(go get 同 module) | 1.8.0 |
| Python | jeeflow.persist 模块(PyPI,随主包) | 1.8.0 |
| Node | src/persist.ts(npm,随主包) | 1.8.0 |