Skip to content

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

版本:1.8.0(起,1.8.0 同步演进) 定位:引擎无关的动态表写入组件 + 引擎适配的入库拦截器,八语言行为对齐(参考实现四语言签名逐字对照,见 §2)。 集成方通过配置声明「流程数据写入 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
}
php
// PHP — jeeflow-php 的 packages/persist(Jeeflow\Persist\DynamicTableWriter;PdoDynamicTableWriter 实现)
interface DynamicTableWriter {
    public function filterColumns(string $tableName, array $columns): array;
    public function insert(string $tableName, array $data): mixed;
    public function update(string $tableName, array $data, string $whereColumn, mixed $whereValue): int;
    public function exists(string $tableName, string $bizKey, mixed $bizKeyValue): bool;
    public function fillSystemFields(array &$data, bool $insert): void;
}
rust
// Rust — jeeflow-persist 的 DynamicTableWriter trait(形状差异见下注)
pub trait DynamicTableWriter: Send + Sync {
    fn insert(&self, table_name: &str, data: &HashMap<String, JsonValue>) -> JeeflowResult<i64>;
    fn update(&self, table_name: &str, id: i64, data: &HashMap<String, JsonValue>) -> JeeflowResult<()>;
    fn exists(&self, table_name: &str, id: i64) -> JeeflowResult<bool>;
    fn exists_by_key(&self, table_name: &str, key: &str, value: i64) -> JeeflowResult<bool>;
    fn update_by_key(&self, table_name: &str, data: &HashMap<String, JsonValue>, key: &str, value: i64) -> JeeflowResult<()>;
    fn columns(&self, _table_name: &str, candidates: &[String]) -> JeeflowResult<Vec<String>> { /* 缺省全保留 */ }
    fn fill_system_fields(&self, data: &mut HashMap<String, JsonValue>, operator: &str, is_insert: bool) { /* … */ }
}
moonbit
// MoonBit — mldong/jeeflow-persist(persist 模块;全 async,形状差异见下注)
pub(open) trait DynamicTableWriter {
  async fn insert(Self, String, Map[String, Json]) -> Int64 raise @error.JeeflowError
  async fn update(Self, String, Int64, Map[String, Json]) -> Unit raise @error.JeeflowError
  async fn exists(Self, String, Int64) -> Bool raise @error.JeeflowError
  async fn exists_by_key(Self, String, String, Int64) -> Bool raise @error.JeeflowError
  async fn update_by_key(Self, String, Map[String, Json], String, Int64) -> Unit raise @error.JeeflowError
  async fn columns(Self, String, Array[String]) -> Array[String] raise @error.JeeflowError
}
csharp
// C# — Mldong.Jeeflow.Persist(IDynamicTableWriter;DbDynamicTableWriter 实现,异步面)
public interface IDynamicTableWriter {
    Task<List<string>> FilterColumnsAsync(string tableName, IEnumerable<string> columns);
    Task<object?> InsertAsync(string tableName, IDictionary<string, object?> data);
    Task<int> UpdateAsync(string tableName, IDictionary<string, object?> data, string whereColumn, object? whereValue);
    Task<bool> ExistsAsync(string tableName, string bizKey, object? bizKeyValue);
    void FillSystemFields(IDictionary<string, object?> data, bool insert);
}

⚠ 后入栈形状差异(2026-10 现读;方法集合与行为语义对应参考形,方法名/参序/同步形状不回填、不作跨栈承诺): Rust 为七法形——列探测名 columns;update/exists 分 by-id 与 by-key 两套(update_by_key/exists_by_key); fill_system_fields 多 operator 参。MoonBit 为六法形、全 async——by-id/by-key 两套同名; fill_system_fields 不在 trait 上(模块级函数)。PHP/C# 为五法形(C# 仅异步后缀差)。

3. 默认实现(JdbcDynamicTableWriter) ​

语言模块/包数据源方言处理
Javajeeflow-persist(Maven Central)DataSourceinformation_schema.columns,UPPER() 比较兼容 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 参数(下划线)不受影响。 本条只管基础 writer 那条轴:元数据腿(MetaTableReader/MetaTableWriter 按元数据列名取键)一律逐字精确,见 规范 10 §5.1;rust 的 persist 只有一处列匹配代码点(=元数据腿),故 1.6.4 在 rust 以公开尺子形式留给集成方自己的 writer
  • 系统字段: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.postInterceptors → ModelParser → ProcessModel(反射实例化); 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_title → title)
流程上下文process_instance_id(实例 ID)/ apply_user_id(发起人)/ apply_dept_id(u_deptId),蛇形列名约定
系统字段writer 通用字段(见 §3),插入/更新模式

4.4 表名解析 ​

  1. 流程定义顶层 relTableName(ProcessModel.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
PHPpackages/persist(随主仓 mldong/jeeflow-php)1.1.2
Rustjeeflow-persist(crates.io,独立 1.0.x 线)1.0.0
MoonBitmldong/jeeflow-persist(mooncakes,独立 0.x 线)0.1.0
C#Mldong.Jeeflow.Persist(NuGet,独立 1.0.x 线)1.0.0

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